@helyx/bot 0.4.9 → 0.4.11

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.
@@ -1,6 +1,9 @@
1
1
  import { z } from "zod";
2
2
  export const SHARD_CONTRACT_VERSION = 1;
3
- export const SHARD_CONTROL_MAX_BYTES = 64 * 1_024;
3
+ // Guild dashboard state includes the bounded module catalogue as well as the
4
+ // guild's Discord resources. Keep this aligned with the cloud-control message
5
+ // ceiling so hosted and self-hosted transports accept the same valid state.
6
+ export const SHARD_CONTROL_MAX_BYTES = 256 * 1_024;
4
7
  export const SHARD_CONTROL_TIMEOUT_MS = 30_000;
5
8
  export const SHARD_HEALTH_STALE_MS = 30_000;
6
9
  export const SHARD_LEASE_HEARTBEAT_TIMEOUT_MS = 20_000;
@@ -356,6 +359,7 @@ export const shardControlOperationSchema = z.discriminatedUnion("kind", [
356
359
  actionId: resourceType,
357
360
  expectedRevision: z.number().int().min(0),
358
361
  value: managedResourceObject,
362
+ actorAuthenticatedAt: dateTime.optional(),
359
363
  ...mutationContext.shape,
360
364
  })
361
365
  .strict(),
@@ -568,11 +572,246 @@ export const shardScheduledTaskResponseSchema = z
568
572
  result: scheduledTaskResult,
569
573
  })
570
574
  .strict();
575
+ const numericWireValueSchema = z.discriminatedUnion("type", [
576
+ z
577
+ .object({ type: z.literal("string"), value: z.string().max(4_000) })
578
+ .strict(),
579
+ z.object({ type: z.literal("boolean"), value: z.boolean() }).strict(),
580
+ z.object({ type: z.literal("number"), value: z.number().finite() }).strict(),
581
+ z.object({ type: z.literal("date"), value: dateTime }).strict(),
582
+ ]);
583
+ const numericWireRecordSchema = z
584
+ .record(z.string().regex(/^[a-z][a-z0-9_]*$/u), numericWireValueSchema)
585
+ .refine((value) => Object.keys(value).length <= 50);
586
+ const numericScopeSchema = z.object({
587
+ moduleId,
588
+ collection: z.string().regex(/^[a-z][a-z0-9_]*$/u),
589
+ });
590
+ const numericMemberScopeSchema = {
591
+ ...numericScopeSchema.shape,
592
+ guildId: snowflake,
593
+ subjectId: safeIdentifier,
594
+ };
595
+ const numericOperationSchema = z
596
+ .discriminatedUnion("kind", [
597
+ z
598
+ .object({
599
+ kind: z.literal("enqueue"),
600
+ ...numericMemberScopeSchema,
601
+ occurredAt: dateTime,
602
+ cooldownSeconds: z.number().int().min(1).max(86_400),
603
+ identity: numericWireRecordSchema,
604
+ insert: numericWireRecordSchema,
605
+ increments: z
606
+ .record(z.string().regex(/^[a-z][a-z0-9_]*$/u), z.number().int().positive())
607
+ .refine((value) => {
608
+ const length = Object.keys(value).length;
609
+ return length >= 1 && length <= 20;
610
+ }),
611
+ maxima: z
612
+ .record(z.string().regex(/^[a-z][a-z0-9_]*$/u), dateTime)
613
+ .refine((value) => Object.keys(value).length <= 20)
614
+ .optional(),
615
+ })
616
+ .strict(),
617
+ z
618
+ .object({
619
+ kind: z.literal("pending.member"),
620
+ ...numericMemberScopeSchema,
621
+ })
622
+ .strict(),
623
+ z
624
+ .object({ kind: z.literal("flush.member"), ...numericMemberScopeSchema })
625
+ .strict(),
626
+ z
627
+ .object({
628
+ kind: z.literal("flush.guild"),
629
+ ...numericScopeSchema.shape,
630
+ guildId: snowflake,
631
+ })
632
+ .strict(),
633
+ z
634
+ .object({
635
+ kind: z.literal("flush.subject"),
636
+ ...numericScopeSchema.shape,
637
+ subjectId: safeIdentifier,
638
+ })
639
+ .strict(),
640
+ z
641
+ .object({ kind: z.literal("flush.module"), ...numericScopeSchema.shape })
642
+ .strict(),
643
+ z
644
+ .object({
645
+ kind: z.literal("barrier.acquire"),
646
+ ...numericScopeSchema.shape,
647
+ barrierType: z.enum({
648
+ member: "member",
649
+ guild: "guild",
650
+ subject: "subject",
651
+ }),
652
+ guildId: snowflake.optional(),
653
+ subjectId: safeIdentifier.optional(),
654
+ })
655
+ .strict(),
656
+ z
657
+ .object({
658
+ kind: z.literal("barrier.release"),
659
+ ...numericScopeSchema.shape,
660
+ barrierType: z.enum({
661
+ member: "member",
662
+ guild: "guild",
663
+ subject: "subject",
664
+ }),
665
+ guildId: snowflake.optional(),
666
+ subjectId: safeIdentifier.optional(),
667
+ barrierId: z.uuid(),
668
+ })
669
+ .strict(),
670
+ z
671
+ .object({
672
+ kind: z.literal("barrier.renew"),
673
+ ...numericScopeSchema.shape,
674
+ barrierType: z.enum({
675
+ member: "member",
676
+ guild: "guild",
677
+ subject: "subject",
678
+ }),
679
+ guildId: snowflake.optional(),
680
+ subjectId: safeIdentifier.optional(),
681
+ barrierId: z.uuid(),
682
+ })
683
+ .strict(),
684
+ z
685
+ .object({
686
+ kind: z.literal("suppress.subject"),
687
+ ...numericScopeSchema.shape,
688
+ subjectId: safeIdentifier,
689
+ cutoff: dateTime,
690
+ })
691
+ .strict(),
692
+ z
693
+ .object({
694
+ kind: z.literal("suppress.guild"),
695
+ ...numericScopeSchema.shape,
696
+ guildId: snowflake,
697
+ })
698
+ .strict(),
699
+ z
700
+ .object({
701
+ kind: z.literal("release.guild"),
702
+ ...numericScopeSchema.shape,
703
+ guildId: snowflake,
704
+ suppressionId: z.uuid(),
705
+ })
706
+ .strict(),
707
+ z
708
+ .object({
709
+ kind: z.literal("release.subject"),
710
+ ...numericScopeSchema.shape,
711
+ subjectId: safeIdentifier,
712
+ suppressionId: z.uuid(),
713
+ })
714
+ .strict(),
715
+ z
716
+ .object({
717
+ kind: z.literal("cooldown.member.clear"),
718
+ ...numericMemberScopeSchema,
719
+ })
720
+ .strict(),
721
+ z
722
+ .object({
723
+ kind: z.literal("cooldown.subject.clear"),
724
+ ...numericScopeSchema.shape,
725
+ subjectId: safeIdentifier,
726
+ })
727
+ .strict(),
728
+ ])
729
+ .superRefine((operation, context) => {
730
+ if (operation.kind !== "barrier.acquire" &&
731
+ operation.kind !== "barrier.release" &&
732
+ operation.kind !== "barrier.renew")
733
+ return;
734
+ const valid = operation.barrierType === "member"
735
+ ? operation.guildId !== undefined && operation.subjectId !== undefined
736
+ : operation.barrierType === "guild"
737
+ ? operation.guildId !== undefined && operation.subjectId === undefined
738
+ : operation.guildId === undefined &&
739
+ operation.subjectId !== undefined;
740
+ if (!valid)
741
+ context.addIssue({
742
+ code: "custom",
743
+ message: "Numeric barrier scope does not match its barrier type",
744
+ });
745
+ });
746
+ export const shardNumericRequestSchema = z
747
+ .object({
748
+ version: z.literal(SHARD_CONTRACT_VERSION),
749
+ type: z.literal("helyx.numeric.request"),
750
+ requestId: z.uuid(),
751
+ deadlineAt: dateTime,
752
+ identity: shardIdentitySchema,
753
+ operation: numericOperationSchema,
754
+ })
755
+ .strict();
756
+ export const shardNumericResponseSchema = z.discriminatedUnion("ok", [
757
+ z
758
+ .object({
759
+ version: z.literal(SHARD_CONTRACT_VERSION),
760
+ type: z.literal("helyx.numeric.response"),
761
+ requestId: z.uuid(),
762
+ ok: z.literal(true),
763
+ result: z.unknown(),
764
+ })
765
+ .strict(),
766
+ z
767
+ .object({
768
+ version: z.literal(SHARD_CONTRACT_VERSION),
769
+ type: z.literal("helyx.numeric.response"),
770
+ requestId: z.uuid(),
771
+ ok: z.literal(false),
772
+ safeCode: z.string().regex(/^[a-z0-9][a-z0-9._-]{0,99}$/u),
773
+ })
774
+ .strict(),
775
+ ]);
776
+ export const shardNumericCommittedSchema = z
777
+ .object({
778
+ version: z.literal(SHARD_CONTRACT_VERSION),
779
+ type: z.literal("helyx.numeric.committed"),
780
+ requestId: z.uuid(),
781
+ deadlineAt: dateTime,
782
+ identity: shardIdentitySchema,
783
+ scope: numericScopeSchema.strict(),
784
+ payload: z.unknown(),
785
+ })
786
+ .strict();
787
+ export const shardNumericCommittedResponseSchema = z.discriminatedUnion("ok", [
788
+ z
789
+ .object({
790
+ version: z.literal(SHARD_CONTRACT_VERSION),
791
+ type: z.literal("helyx.numeric.committed.response"),
792
+ requestId: z.uuid(),
793
+ identity: shardIdentitySchema,
794
+ ok: z.literal(true),
795
+ })
796
+ .strict(),
797
+ z
798
+ .object({
799
+ version: z.literal(SHARD_CONTRACT_VERSION),
800
+ type: z.literal("helyx.numeric.committed.response"),
801
+ requestId: z.uuid(),
802
+ identity: shardIdentitySchema,
803
+ ok: z.literal(false),
804
+ safeCode: z.string().regex(/^[a-z0-9][a-z0-9._-]{0,99}$/u),
805
+ })
806
+ .strict(),
807
+ ]);
571
808
  export const supervisorMessageSchema = z.discriminatedUnion("type", [
572
809
  shardControlRequestSchema,
573
810
  shardCloudResponseSchema,
574
811
  shardOperationsHealthResponseSchema,
575
812
  shardScheduledTaskRequestSchema,
813
+ shardNumericResponseSchema,
814
+ shardNumericCommittedSchema,
576
815
  z
577
816
  .object({
578
817
  version: z.literal(SHARD_CONTRACT_VERSION),
@@ -599,6 +838,8 @@ export const workerMessageSchema = z.discriminatedUnion("type", [
599
838
  shardCloudRequestSchema,
600
839
  shardOperationsHealthRequestSchema,
601
840
  shardScheduledTaskResponseSchema,
841
+ shardNumericRequestSchema,
842
+ shardNumericCommittedResponseSchema,
602
843
  z
603
844
  .object({
604
845
  version: z.literal(SHARD_CONTRACT_VERSION),
@@ -759,6 +1000,9 @@ export async function executeShardControlOperation(handler, operation) {
759
1000
  value: operation.value,
760
1001
  actorUserId: operation.actorUserId,
761
1002
  correlationId: operation.correlationId,
1003
+ ...(operation.actorAuthenticatedAt
1004
+ ? { actorAuthenticatedAt: operation.actorAuthenticatedAt }
1005
+ : {}),
762
1006
  });
763
1007
  case "support.notification.send": {
764
1008
  return handler.sendSupportNotification({
@@ -1,6 +1,6 @@
1
1
  import type { BotEnvironment } from "@helyx/config";
2
2
  import { type DatabasePool } from "@helyx/database";
3
- import type { ModuleDefinition, ScheduledTaskExecutionResult } from "@helyx/sdk";
3
+ import type { ModuleDefinition, NumericWriteBehindCommittedBatch, ScheduledTaskExecutionResult } from "@helyx/sdk";
4
4
  import { type ShardCloudRequest, type ShardIdentity, type ShardProcessHealth } from "./shard-contracts.js";
5
5
  import { type ShardCoordinator, type ShardGenerationDescriptor, type ShardLeaderLease, type ShardLease } from "./shard-coordinator.js";
6
6
  import type { BotHandle } from "./index.js";
@@ -10,6 +10,7 @@ export declare function createStartupWorkerHeartbeatMaintenance(heartbeat: () =>
10
10
  };
11
11
  export declare function deferScheduledRoutingFailures(route: () => Promise<ScheduledTaskExecutionResult>): Promise<ScheduledTaskExecutionResult>;
12
12
  export declare function startShardSupervisor(environment: BotEnvironment, processEnvironment: NodeJS.ProcessEnv, workerEntry: string): Promise<BotHandle>;
13
+ export declare function splitNumericCommittedBatch(batch: NumericWriteBehindCommittedBatch, index: number): NumericWriteBehindCommittedBatch;
13
14
  export declare function buildShardWorkerEnvironment(source: NodeJS.ProcessEnv, identity: ShardIdentity, restartCount: number): NodeJS.ProcessEnv;
14
15
  export declare function shardRuntimeCompositionHash(modules: readonly ModuleDefinition[], internalGuildId?: string): string;
15
16
  export declare function boundedRespawnDelay(attempt: number): number;
@@ -1,6 +1,7 @@
1
1
  import { safeError, ServiceRegistry } from "@helyx/core";
2
2
  import { checkDatabaseConnection, createDatabasePool, InteractionSessionRepository, RateLimitRepository, runConfiguredModuleMigrations, runCoreMigrations, RuntimeStateRepository, } from "@helyx/database";
3
3
  import { createPlatformServices, PostgresPrivacyProviderExecutor, } from "@helyx/platform";
4
+ import { HELYX_SERVICE_NAMES } from "@helyx/sdk";
4
5
  import { Client, ShardingManager } from "discord.js";
5
6
  import { createHash, randomUUID } from "node:crypto";
6
7
  import { createRequire } from "node:module";
@@ -15,13 +16,16 @@ import { buildApplicationCommandDefinitions } from "./interactions.js";
15
16
  import { resolveModulePackageSpecifiers } from "./module-configuration.js";
16
17
  import { deriveSecureRandomSelectionKey, loadConfiguredModulePackages, platformServiceOptions, secureRandomSelectionWorkerKeyName, } from "./modules.js";
17
18
  import { createPermissionsModule } from "./permissions-module.js";
18
- import { SHARD_CONTROL_MAX_BYTES, SHARD_HEALTH_STALE_MS, assertShardMessageSize, controlOperationGuildId, executeShardControlOperation, gatewayBotInformationSchema, guildIdToShardId, shardCloudRequestSchema, shardHealthSnapshotSchema, workerMessageSchema, } from "./shard-contracts.js";
19
+ import { SHARD_CONTROL_MAX_BYTES, SHARD_CONTROL_TIMEOUT_MS, SHARD_HEALTH_STALE_MS, assertShardMessageSize, controlOperationGuildId, executeShardControlOperation, gatewayBotInformationSchema, guildIdToShardId, shardCloudRequestSchema, shardHealthSnapshotSchema, workerMessageSchema, } from "./shard-contracts.js";
19
20
  import { RedisShardCoordinator, } from "./shard-coordinator.js";
20
21
  import { ShardControlRouter } from "./shard-router.js";
21
22
  import { createDeveloperOperationsModule } from "./developer-operations-module.js";
22
23
  import { SupervisorCloudDiscordDirectory } from "./supervisor-cloud-directory.js";
23
24
  import { createSupportModule } from "./support-module.js";
24
25
  import { createScheduledTaskRunner, schedulerOperationalHealth, } from "./scheduled-task-runner.js";
26
+ import { numericWriteBehindRegistrations } from "./numeric-write-behind.js";
27
+ import { executeRedisNumericOperation, RedisNumericWriteBehindService, } from "./numeric-write-behind-redis.js";
28
+ import { encodeNumericIpcValue } from "./numeric-write-behind-proxy.js";
25
29
  const LEASE_RENEW_INTERVAL_MS = 10_000;
26
30
  const STARTUP_LEASE_RENEW_INTERVAL_MS = 5_000;
27
31
  const LEASE_VALIDATION_TIMEOUT_MS = 1_000;
@@ -50,6 +54,14 @@ export async function deferScheduledRoutingFailures(route) {
50
54
  };
51
55
  }
52
56
  }
57
+ function sameNumericIdentity(left, right) {
58
+ return (left.generation === right.generation &&
59
+ left.deploymentOrder === right.deploymentOrder &&
60
+ left.supervisorId === right.supervisorId &&
61
+ left.shardId === right.shardId &&
62
+ left.shardCount === right.shardCount &&
63
+ left.fencingToken === right.fencingToken);
64
+ }
53
65
  export async function startShardSupervisor(environment, processEnvironment, workerEntry) {
54
66
  if (environment.HELYX_SHARD_MODE === "off")
55
67
  throw new Error("Shard supervisor requires sharding to be enabled");
@@ -72,6 +84,8 @@ export async function startShardSupervisor(environment, processEnvironment, work
72
84
  let privacyDatabase;
73
85
  let controlServer;
74
86
  let coordinator;
87
+ let numericWriteBehind;
88
+ let numericRegistrations = [];
75
89
  let leaderLease;
76
90
  let cloudConnector;
77
91
  let cloudStarted = false;
@@ -88,6 +102,7 @@ export async function startShardSupervisor(environment, processEnvironment, work
88
102
  const healthByShard = new Map();
89
103
  const pendingControl = new Map();
90
104
  const pendingScheduled = new Map();
105
+ const pendingNumericCommitted = new Map();
91
106
  const restartHistory = new Map();
92
107
  const restartCounts = new Map();
93
108
  const workers = new Map();
@@ -160,6 +175,7 @@ export async function startShardSupervisor(environment, processEnvironment, work
160
175
  ...(cloudManagementModule ? [cloudManagementModule] : []),
161
176
  ...featureModules,
162
177
  ];
178
+ numericRegistrations = numericWriteBehindRegistrations(publicModules);
163
179
  scheduledModules = publicModules;
164
180
  const operationsModule = environment.DISCORD_INTERNAL_GUILD_ID
165
181
  ? createDeveloperOperationsModule({
@@ -194,6 +210,8 @@ export async function startShardSupervisor(environment, processEnvironment, work
194
210
  privacyDatabase = await createPrivacyDatabase(environment, database);
195
211
  assertStartupLeaseHealthy();
196
212
  const services = new Map(createPlatformServices(database, publicModules, platformServiceOptions(environment, publicModules)));
213
+ if (numericWriteBehind)
214
+ services.set(HELYX_SERVICE_NAMES.numericWriteBehind, numericWriteBehind);
197
215
  privacyService = new BotControlService(database, publicModules, unavailableResources, new ServiceRegistry(services), privacyDatabase
198
216
  ? new PostgresPrivacyProviderExecutor({
199
217
  readPool: database,
@@ -233,6 +251,21 @@ export async function startShardSupervisor(environment, processEnvironment, work
233
251
  });
234
252
  supervisorId = coordinator.supervisorId;
235
253
  await coordinator.start((targetShardId, request) => dispatchLocal(targetShardId, request), (request) => executeLeaderControl(request), (request) => executeLeaderCloud(request), (targetShardId, request) => dispatchLocalScheduled(targetShardId, request));
254
+ const numericPlatformServices = createPlatformServices(database, publicModules, platformServiceOptions(environment, publicModules));
255
+ numericWriteBehind = new RedisNumericWriteBehindService({
256
+ redisUrl: environment.HELYX_SHARD_REDIS_URL,
257
+ namespace: environment.HELYX_SHARD_REDIS_NAMESPACE,
258
+ writer: numericPlatformServices.get(HELYX_SERVICE_NAMES.records),
259
+ registrations: numericRegistrations,
260
+ supervisorId,
261
+ shardLeaseKeyPrefix: `helyx:shard:v1:${environment.HELYX_SHARD_REDIS_NAMESPACE}:lease:shard:`,
262
+ onError: (error) => logger.error({
263
+ event: "numeric_write_behind.redis_failed",
264
+ error: safeError(error),
265
+ }, "Persistent numeric write-behind operation failed"),
266
+ });
267
+ await numericWriteBehind.start();
268
+ installNumericCommitRouting(numericWriteBehind);
236
269
  deploymentOrder = await coordinator.assertGeneration(generationDescriptor, deploymentOrder);
237
270
  leaderLease = await coordinator.acquireLeader(generationDescriptor, deploymentOrder);
238
271
  startStartupLeaseMaintenance();
@@ -458,6 +491,135 @@ export async function startShardSupervisor(environment, processEnvironment, work
458
491
  ?.send(heartbeat)
459
492
  .catch(() => undefined);
460
493
  }
494
+ function installNumericCommitRouting(service) {
495
+ for (const registration of numericRegistrations) {
496
+ service.subscribeCommitted(registration, async (batch) => {
497
+ for (let index = 0; index < batch.mutations.length; index += 1) {
498
+ const mutation = batch.mutations[index];
499
+ const result = batch.results[index];
500
+ const guildId = mutation?.identity.guild_id;
501
+ if (typeof guildId !== "string" || !result)
502
+ continue;
503
+ const committedItem = splitNumericCommittedBatch(batch, index);
504
+ const shardId = guildIdToShardId(guildId, shardCount);
505
+ const lease = localLeases.get(shardId);
506
+ const worker = workers.get(shardId);
507
+ if (!lease || !worker?.process)
508
+ throw new Error("Committed numeric batch owner is not local or available");
509
+ const identity = {
510
+ version: 1,
511
+ mode: shardMode,
512
+ scope: environment.HELYX_SHARD_SCOPE,
513
+ generation: lease.generation,
514
+ deploymentOrder: lease.deploymentOrder,
515
+ supervisorId: lease.supervisorId,
516
+ shardId,
517
+ shardCount,
518
+ fencingToken: lease.fencingToken,
519
+ };
520
+ const notification = {
521
+ version: 1,
522
+ type: "helyx.numeric.committed",
523
+ requestId: randomUUID(),
524
+ deadlineAt: new Date(Date.now() + SHARD_CONTROL_TIMEOUT_MS).toISOString(),
525
+ identity,
526
+ scope: {
527
+ moduleId: batch.moduleId,
528
+ collection: batch.collection,
529
+ },
530
+ payload: encodeNumericIpcValue(committedItem),
531
+ };
532
+ assertShardMessageSize(notification);
533
+ const response = await dispatchNumericCommitted(shardId, identity, notification);
534
+ if (!response.ok)
535
+ throw new Error(`Committed numeric batch was not applied: ${response.safeCode}`);
536
+ }
537
+ });
538
+ }
539
+ }
540
+ function dispatchNumericCommitted(shardId, identity, notification) {
541
+ const worker = workers.get(shardId);
542
+ if (!worker?.process)
543
+ return Promise.reject(new Error("Committed numeric batch owner is unavailable"));
544
+ return new Promise((resolve, reject) => {
545
+ const timer = setTimeout(() => {
546
+ pendingNumericCommitted.delete(notification.requestId);
547
+ reject(new Error("Committed numeric batch acknowledgement timed out"));
548
+ }, SHARD_CONTROL_TIMEOUT_MS);
549
+ timer.unref();
550
+ pendingNumericCommitted.set(notification.requestId, {
551
+ shardId,
552
+ identity,
553
+ resolve,
554
+ timer,
555
+ });
556
+ void worker.send(notification).catch((error) => {
557
+ clearTimeout(timer);
558
+ pendingNumericCommitted.delete(notification.requestId);
559
+ reject(error instanceof Error
560
+ ? error
561
+ : new Error("Committed numeric delivery failed"));
562
+ });
563
+ });
564
+ }
565
+ async function handleNumericRequest(shardId, request) {
566
+ const lease = localLeases.get(shardId);
567
+ const numericService = numericWriteBehind;
568
+ const guildId = "guildId" in request.operation ? request.operation.guildId : undefined;
569
+ const valid = numericService !== undefined &&
570
+ lease !== undefined &&
571
+ request.identity.shardId === shardId &&
572
+ request.identity.shardCount === shardCount &&
573
+ request.identity.generation === lease.generation &&
574
+ request.identity.supervisorId === lease.supervisorId &&
575
+ request.identity.fencingToken === lease.fencingToken &&
576
+ request.identity.deploymentOrder === lease.deploymentOrder &&
577
+ request.identity.deploymentOrder !== undefined &&
578
+ new Date(request.deadlineAt).getTime() > Date.now() &&
579
+ (!guildId || guildIdToShardId(guildId, shardCount) === shardId);
580
+ let response;
581
+ if (!valid) {
582
+ response = {
583
+ version: 1,
584
+ type: "helyx.numeric.response",
585
+ requestId: request.requestId,
586
+ ok: false,
587
+ safeCode: "stale_owner",
588
+ };
589
+ }
590
+ else {
591
+ try {
592
+ if (!numericService)
593
+ throw new Error("Numeric write-behind service is unavailable");
594
+ response = {
595
+ version: 1,
596
+ type: "helyx.numeric.response",
597
+ requestId: request.requestId,
598
+ ok: true,
599
+ result: encodeNumericIpcValue(await executeRedisNumericOperation(numericService, request.operation, {
600
+ identity: request.identity,
601
+ shardLeaseKeyPrefix: `helyx:shard:v1:${environment.HELYX_SHARD_REDIS_NAMESPACE}:lease:shard:`,
602
+ })),
603
+ };
604
+ }
605
+ catch (error) {
606
+ logger.error({
607
+ event: "numeric_write_behind.ipc_failed",
608
+ shardId,
609
+ error: safeError(error),
610
+ }, "Numeric write-behind IPC operation failed");
611
+ response = {
612
+ version: 1,
613
+ type: "helyx.numeric.response",
614
+ requestId: request.requestId,
615
+ ok: false,
616
+ safeCode: "operation_failed",
617
+ };
618
+ }
619
+ }
620
+ assertShardMessageSize(response);
621
+ await workers.get(shardId)?.send(response);
622
+ }
461
623
  async function handleWorkerMessage(shardId, raw) {
462
624
  if (serializedBytes(raw) > SHARD_CONTROL_MAX_BYTES)
463
625
  return;
@@ -495,6 +657,21 @@ export async function startShardSupervisor(environment, processEnvironment, work
495
657
  pending.resolve(message.data);
496
658
  return;
497
659
  }
660
+ if (message.data.type === "helyx.numeric.committed.response") {
661
+ const pending = pendingNumericCommitted.get(message.data.requestId);
662
+ if (!pending ||
663
+ pending.shardId !== shardId ||
664
+ !sameNumericIdentity(pending.identity, message.data.identity))
665
+ return;
666
+ pendingNumericCommitted.delete(message.data.requestId);
667
+ clearTimeout(pending.timer);
668
+ pending.resolve(message.data);
669
+ return;
670
+ }
671
+ if (message.data.type === "helyx.numeric.request") {
672
+ await handleNumericRequest(shardId, message.data);
673
+ return;
674
+ }
498
675
  if (message.data.type === "helyx.cloud.request") {
499
676
  const response = leaderLease
500
677
  ? await executeLeaderCloud(message.data)
@@ -1014,6 +1191,23 @@ export async function startShardSupervisor(environment, processEnvironment, work
1014
1191
  if (shard.process)
1015
1192
  shard.kill();
1016
1193
  }));
1194
+ if (numericWriteBehind) {
1195
+ try {
1196
+ const { drained } = await numericWriteBehind.stop();
1197
+ if (!drained) {
1198
+ process.exitCode = 1;
1199
+ logger.error({ event: "numeric_write_behind.shutdown_not_drained", reason }, "Numeric write-behind exceeded its bounded shutdown drain");
1200
+ }
1201
+ }
1202
+ catch (error) {
1203
+ process.exitCode = 1;
1204
+ logger.error({
1205
+ event: "numeric_write_behind.shutdown_failed",
1206
+ reason,
1207
+ error: safeError(error),
1208
+ }, "Numeric write-behind shutdown failed");
1209
+ }
1210
+ }
1017
1211
  await cloudConnector?.stop().catch(() => undefined);
1018
1212
  if (coordinator) {
1019
1213
  const activeCoordinator = coordinator;
@@ -1027,6 +1221,18 @@ export async function startShardSupervisor(environment, processEnvironment, work
1027
1221
  logger.info({ event: "shard.supervisor_stopped", reason }, "Shard supervisor stopped");
1028
1222
  }
1029
1223
  }
1224
+ export function splitNumericCommittedBatch(batch, index) {
1225
+ const mutation = batch.mutations[index];
1226
+ const result = batch.results[index];
1227
+ if (!Number.isSafeInteger(index) || index < 0 || !mutation || !result)
1228
+ throw new Error("Numeric committed batch item is unavailable");
1229
+ return {
1230
+ ...batch,
1231
+ itemIndexes: [batch.itemIndexes?.[index] ?? index],
1232
+ mutations: [mutation],
1233
+ results: [result],
1234
+ };
1235
+ }
1030
1236
  async function runLeaderStartup(database, environment, modulePackages, modules, logger) {
1031
1237
  await runCoreMigrations(database);
1032
1238
  await runConfiguredModuleMigrations(database, modulePackages.map(({ definition, packageRoot }) => ({
@@ -1138,6 +1344,7 @@ export function buildShardWorkerEnvironment(source, identity, restartCount) {
1138
1344
  "DISCORD_INTERNAL_GUILD_ID",
1139
1345
  "HELYX_SUPPORT_STAFF_CHANNEL_ID",
1140
1346
  "HELYX_MODULE_PACKAGES",
1347
+ "HELYX_MESSAGE_CONTENT_ENABLED",
1141
1348
  "HELYX_MODULE_CATALOGUE_PATH",
1142
1349
  "HELYX_CLOUD_CONNECT_ENABLED",
1143
1350
  "HELYX_OWNER_USER_IDS",
@@ -37,6 +37,7 @@ export declare function parseShardWorkerEnvironment(processEnvironment: NodeJS.P
37
37
  HELYX_SUPPORT_STAFF_CHANNEL_ID: string | undefined;
38
38
  HELYX_BOT_CONTROL_PORT: number;
39
39
  HELYX_PRIVACY_ENABLED: boolean;
40
+ HELYX_MESSAGE_CONTENT_ENABLED: boolean;
40
41
  HELYX_CLOUD_CONNECT_ENABLED: boolean;
41
42
  HELYX_CLOUD_ENROLMENT_TOKEN: string | undefined;
42
43
  HELYX_CLOUD_ENROLMENT_FILE: string;