@helyx/bot 1.3.0 → 1.4.0

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 (83) hide show
  1. package/CHANGELOG.md +92 -28
  2. package/README.md +65 -11
  3. package/dist/cloud-connector.d.ts +11 -4
  4. package/dist/cloud-connector.js +52 -15
  5. package/dist/cloud-control-dispatcher.d.ts +8 -3
  6. package/dist/cloud-control-dispatcher.js +62 -4
  7. package/dist/control-errors.d.ts +4 -0
  8. package/dist/control-errors.js +8 -0
  9. package/dist/control-server-schemas.d.ts +84 -0
  10. package/dist/control-server-schemas.js +101 -0
  11. package/dist/control-server.d.ts +21 -7
  12. package/dist/control-server.js +88 -132
  13. package/dist/core-retention.d.ts +26 -0
  14. package/dist/core-retention.js +49 -0
  15. package/dist/creator-guild-join-service.d.ts +25 -0
  16. package/dist/creator-guild-join-service.js +218 -0
  17. package/dist/creator-invite-service.d.ts +37 -0
  18. package/dist/creator-invite-service.js +686 -0
  19. package/dist/creator-onboarding-control.d.ts +12 -0
  20. package/dist/creator-onboarding-control.js +65 -0
  21. package/dist/creator-onboarding-platform.d.ts +20 -0
  22. package/dist/creator-onboarding-platform.js +58 -0
  23. package/dist/dashboard-action-control.d.ts +2 -2
  24. package/dist/dashboard-action-control.js +2 -3
  25. package/dist/database-bootstrap.d.ts +36 -0
  26. package/dist/database-bootstrap.js +101 -0
  27. package/dist/developer-operations-module.d.ts +1 -0
  28. package/dist/developer-operations-module.js +34 -16
  29. package/dist/discord-owned-message-service.d.ts +1 -0
  30. package/dist/discord-owned-message-service.js +3 -0
  31. package/dist/guild-command-read-service.d.ts +8 -3
  32. package/dist/guild-command-read-service.js +14 -7
  33. package/dist/guild-command-release.d.ts +15 -8
  34. package/dist/guild-command-release.js +10 -3
  35. package/dist/guild-command-worker.d.ts +17 -4
  36. package/dist/guild-command-worker.js +17 -6
  37. package/dist/guilds.d.ts +24 -3
  38. package/dist/guilds.js +185 -64
  39. package/dist/hosted-server-moderation.d.ts +2 -2
  40. package/dist/hosted-server-moderation.js +10 -4
  41. package/dist/index.d.ts +1 -0
  42. package/dist/index.js +200 -81
  43. package/dist/interactions.js +16 -3
  44. package/dist/module-configuration.d.ts +1 -1
  45. package/dist/module-configuration.js +5 -2
  46. package/dist/module-logging.d.ts +8 -3
  47. package/dist/module-logging.js +13 -7
  48. package/dist/modules.d.ts +1 -0
  49. package/dist/modules.js +8 -1
  50. package/dist/mysql-runtime-persistence.d.ts +46 -0
  51. package/dist/mysql-runtime-persistence.js +91 -0
  52. package/dist/oauth-module.js +2 -2
  53. package/dist/oauth-service.d.ts +7 -0
  54. package/dist/oauth-service.js +3 -0
  55. package/dist/scheduled-task-runner.d.ts +9 -85
  56. package/dist/scheduled-task-runner.js +1 -1
  57. package/dist/shard-contracts.d.ts +135 -0
  58. package/dist/shard-contracts.js +13 -1
  59. package/dist/shard-router.d.ts +2 -1
  60. package/dist/shard-router.js +7 -0
  61. package/dist/shard-supervisor.js +43 -9
  62. package/dist/shard-worker.d.ts +5 -2
  63. package/dist/shard-worker.js +46 -5
  64. package/dist/supervisor-cloud-directory.d.ts +9 -3
  65. package/dist/supervisor-cloud-directory.js +8 -12
  66. package/dist/trivia-data-provider.d.ts +11 -0
  67. package/dist/trivia-data-provider.js +46 -26
  68. package/dist/verification-service.d.ts +2 -2
  69. package/dist/verification-service.js +6 -3
  70. package/documentation/README.md +5 -3
  71. package/documentation/examples/.env.example +15 -11
  72. package/documentation/guides/getting-started.md +29 -12
  73. package/documentation/guides/privacy-and-support.md +6 -5
  74. package/documentation/guides/self-hosting.md +16 -11
  75. package/documentation/modules/auto-moderation.md +7 -5
  76. package/documentation/modules/building-a-module.md +48 -2
  77. package/documentation/modules/moderation.md +5 -5
  78. package/documentation/modules/publishing-a-module.md +6 -4
  79. package/documentation/modules/testing-a-module.md +39 -4
  80. package/documentation/reference/environment.md +43 -9
  81. package/documentation/runbooks/backup-and-recovery.md +26 -7
  82. package/documentation/runbooks/installation-and-upgrades.md +97 -27
  83. package/package.json +20 -16
@@ -1,4 +1,5 @@
1
- import { GuildCommandRepository, type GuildCommandHealth, type GuildCommandRequestRecord } from "@helyx/database";
1
+ import { GuildCommandRepository, InstallationRepository, ModuleAvailabilityRepository, type GuildCommandHealth, type GuildCommandRequestRecord } from "@helyx/database";
2
+ import { type GuildCommandRepository as MysqlGuildCommandRepository, type InstallationRepository as MysqlInstallationRepository, type ModuleAvailabilityRepository as MysqlModuleAvailabilityRepository } from "@helyx/database-mysql";
2
3
  import type { Logger, ModuleDefinition } from "@helyx/sdk";
3
4
  import type { Pool } from "pg";
4
5
  import { type GuildCommandDiff, type GuildCommandProjection } from "./guild-command-projection.js";
@@ -9,10 +10,22 @@ export interface GuildCommandWorkerHealth extends GuildCommandHealth {
9
10
  workerRunning: boolean;
10
11
  activeCount: number;
11
12
  }
13
+ type WorkerCommandRepository = Pick<GuildCommandRepository, "claimDue" | "getHealth" | "heartbeat" | "getState" | "completeDryRun" | "defer" | "markBlocked" | "markFailed" | "completeLive" | "isLeaseCurrent" | "reserveCreations" | "settleCreation"> & Pick<MysqlGuildCommandRepository, "claimDue" | "getHealth" | "heartbeat" | "getState" | "completeDryRun" | "defer" | "markBlocked" | "markFailed" | "completeLive" | "isLeaseCurrent" | "reserveCreations" | "settleCreation">;
14
+ export interface GuildCommandWorkerRepositories {
15
+ commands: WorkerCommandRepository;
16
+ installations: Pick<InstallationRepository, "listEnabledModules"> & Pick<MysqlInstallationRepository, "listEnabledModules">;
17
+ availability: Pick<ModuleAvailabilityRepository, "list"> & Pick<MysqlModuleAvailabilityRepository, "list">;
18
+ }
19
+ type WorkerPersistence = {
20
+ database: Pool;
21
+ repositories?: never;
22
+ } | {
23
+ database?: never;
24
+ repositories: GuildCommandWorkerRepositories;
25
+ };
12
26
  export declare class GuildCommandWorker {
13
27
  #private;
14
28
  constructor(input: {
15
- database: Pool;
16
29
  modules: readonly ModuleDefinition[];
17
30
  logger: Logger;
18
31
  workerId?: string;
@@ -24,13 +37,13 @@ export declare class GuildCommandWorker {
24
37
  claimLimit?: number;
25
38
  concurrency?: number;
26
39
  stopTimeoutMs?: number;
27
- });
40
+ } & WorkerPersistence);
28
41
  start(): void;
29
42
  runOnce(): Promise<void>;
30
43
  stop(): Promise<void>;
31
44
  getHealth(): Promise<GuildCommandWorkerHealth>;
32
45
  }
33
- type LiveRepository = Pick<GuildCommandRepository, "completeLive" | "isLeaseCurrent" | "reserveCreations" | "settleCreation">;
46
+ type LiveRepository = Pick<GuildCommandRepository, "completeLive" | "isLeaseCurrent" | "reserveCreations" | "settleCreation"> & Pick<MysqlGuildCommandRepository, "completeLive" | "isLeaseCurrent" | "reserveCreations" | "settleCreation">;
34
47
  export declare function reconcileLiveGuildCommands(input: {
35
48
  repository: LiveRepository;
36
49
  discord: Pick<GuildCommandDiscordClient, "observe" | "replace">;
@@ -1,4 +1,5 @@
1
1
  import { GuildCommandBudgetExceededError, GuildCommandRepository, InstallationRepository, ModuleAvailabilityRepository, } from "@helyx/database";
2
+ import { GuildCommandBudgetExceededError as MysqlGuildCommandBudgetExceededError, } from "@helyx/database-mysql";
2
3
  import { randomUUID } from "node:crypto";
3
4
  import { diffDryRunGuildCommands, diffGuildCommands, projectGuildCommands, } from "./guild-command-projection.js";
4
5
  const DEFAULT_POLL_INTERVAL_MS = 1_000;
@@ -8,7 +9,8 @@ const DEFAULT_CONCURRENCY = 2;
8
9
  const DEFAULT_STOP_TIMEOUT_MS = 5_000;
9
10
  export class GuildCommandWorker {
10
11
  #repository;
11
- #database;
12
+ #installations;
13
+ #availability;
12
14
  #modules;
13
15
  #logger;
14
16
  #workerId;
@@ -28,8 +30,16 @@ export class GuildCommandWorker {
28
30
  constructor(input) {
29
31
  if (input.fencingToken < 1n)
30
32
  throw new Error("Guild command worker fencing token must be positive");
31
- this.#database = input.database;
32
- this.#repository = new GuildCommandRepository(input.database);
33
+ const repositories = input.database
34
+ ? {
35
+ commands: new GuildCommandRepository(input.database),
36
+ installations: new InstallationRepository(input.database),
37
+ availability: new ModuleAvailabilityRepository(input.database),
38
+ }
39
+ : input.repositories;
40
+ this.#repository = repositories.commands;
41
+ this.#installations = repositories.installations;
42
+ this.#availability = repositories.availability;
33
43
  this.#modules = input.modules;
34
44
  this.#logger = input.logger;
35
45
  this.#workerId = input.workerId ?? `guild-command:${randomUUID()}`;
@@ -145,8 +155,8 @@ export class GuildCommandWorker {
145
155
  heartbeat.unref();
146
156
  try {
147
157
  const [enabled, availability, state] = await Promise.all([
148
- new InstallationRepository(this.#database).listEnabledModules(request.guildId),
149
- new ModuleAvailabilityRepository(this.#database).list(),
158
+ this.#installations.listEnabledModules(request.guildId),
159
+ this.#availability.list(),
150
160
  this.#repository.getState(request.applicationId, request.guildId),
151
161
  ]);
152
162
  if (!state || state.desiredRevision !== request.requestedRevision) {
@@ -310,7 +320,8 @@ export async function reconcileLiveGuildCommands(input) {
310
320
  })).ledgerId;
311
321
  }
312
322
  catch (error) {
313
- if (error instanceof GuildCommandBudgetExceededError)
323
+ if (error instanceof GuildCommandBudgetExceededError ||
324
+ error instanceof MysqlGuildCommandBudgetExceededError)
314
325
  throw new GuildCommandLiveError("creation_budget_exhausted");
315
326
  throw error;
316
327
  }
package/dist/guilds.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { type DatabasePool, type GuildShardOwnership } from "@helyx/database";
1
+ import { InstallationRepository, type DatabasePool, type GuildShardOwnership } from "@helyx/database";
2
+ import type { MysqlPool } from "@helyx/database-mysql";
2
3
  import type { ModuleDefinition } from "@helyx/sdk";
3
4
  import { type Client } from "discord.js";
4
5
  import type { InteractionLogger } from "./interactions.js";
@@ -20,7 +21,27 @@ export interface GuildTrackingPreparation {
20
21
  (readyClient: Client<true>): Promise<void>;
21
22
  reconcile(reason: "ready" | "resume" | "periodic"): Promise<void>;
22
23
  health(): GuildReconciliationHealth;
23
- stop(): void;
24
+ stop(): Promise<void>;
24
25
  }
25
- export declare function installGuildTracking(client: Client, database: DatabasePool, modules: readonly ModuleDefinition[], logger: InteractionLogger, enabledByDefaultModuleIds?: ReadonlySet<string>, ownership?: GuildShardOwnership, reconciliationIntervalMs?: number, shardCount?: number, applicationId?: string, hostedSafety?: HostedGuildSafety, hostedInviteGate?: HostedBotInviteGate, hostedOwnerObserver?: HostedGuildOwnerObserver | undefined): GuildTrackingPreparation;
26
+ interface GuildObservation {
27
+ guildId: string;
28
+ ownerUserId: string;
29
+ ownerUsername?: string;
30
+ name: string;
31
+ modules: readonly ModuleDefinition[];
32
+ enabledByDefaultModuleIds: ReadonlySet<string>;
33
+ firstObservation: boolean;
34
+ applicationId?: string;
35
+ ownership?: GuildShardOwnership;
36
+ continueObservation?: () => boolean;
37
+ }
38
+ type ReconciliationRepository = Pick<InstallationRepository, "listActiveGuildIdsForOwnership" | "adoptGuildsForReconciliation" | "markGuildsRemovedForReconciliation">;
39
+ export interface GuildTrackingPersistence extends ReconciliationRepository {
40
+ readonly engine: "postgresql" | "mysql";
41
+ syncGuild(input: GuildObservation): Promise<void>;
42
+ markGuildRemoved(guildId: string, ownership: GuildShardOwnership | undefined, applicationId: string | undefined): Promise<boolean>;
43
+ }
44
+ export declare function createMysqlGuildTrackingPersistence(database: MysqlPool): Promise<GuildTrackingPersistence>;
45
+ export declare function installGuildTracking(client: Client, database: DatabasePool | GuildTrackingPersistence, modules: readonly ModuleDefinition[], logger: InteractionLogger, enabledByDefaultModuleIds?: ReadonlySet<string>, ownership?: GuildShardOwnership, reconciliationIntervalMs?: number, shardCount?: number, applicationId?: string, hostedSafety?: HostedGuildSafety, hostedInviteGate?: HostedBotInviteGate, hostedOwnerObserver?: HostedGuildOwnerObserver | undefined): GuildTrackingPreparation;
46
+ export {};
26
47
  //# sourceMappingURL=guilds.d.ts.map
package/dist/guilds.js CHANGED
@@ -3,14 +3,122 @@ import { safeError } from "@helyx/core";
3
3
  import { Events } from "discord.js";
4
4
  const RECONCILIATION_PAGE_SIZE = 250;
5
5
  const DEFAULT_RECONCILIATION_INTERVAL_MS = 15 * 60 * 1_000;
6
+ async function writeGuildObservation(installations, commands, input) {
7
+ const record = await installations.syncGuild({
8
+ guildId: input.guildId,
9
+ ownerUserId: input.ownerUserId,
10
+ ...(input.ownerUsername ? { ownerUsername: input.ownerUsername } : {}),
11
+ name: input.name,
12
+ ...(input.ownership ? { ownership: input.ownership } : {}),
13
+ });
14
+ const ownership = input.ownership;
15
+ if (ownership &&
16
+ (record.shardGeneration !== ownership.generation ||
17
+ record.shardDeploymentOrder !== (ownership.deploymentOrder ?? 1n) ||
18
+ record.shardSupervisorId !== ownership.supervisorId ||
19
+ record.shardId !== ownership.shardId ||
20
+ record.shardFencingToken !== ownership.fencingToken))
21
+ throw new Error("Guild observation was rejected by a newer shard owner");
22
+ if (input.firstObservation && input.applicationId)
23
+ await commands.ensureState({
24
+ applicationId: input.applicationId,
25
+ guildId: input.guildId,
26
+ setupState: "incomplete",
27
+ });
28
+ for (const module of input.modules) {
29
+ // First observations remain atomic; refreshes can stop between writes.
30
+ if (!input.firstObservation && input.continueObservation?.() === false)
31
+ return;
32
+ await installations.ensureModule({
33
+ guildId: input.guildId,
34
+ moduleId: module.manifest.id,
35
+ moduleVersion: module.manifest.version,
36
+ configurationSchemaVersion: module.configuration?.schemaVersion ?? 1,
37
+ enabledBy: input.ownerUserId,
38
+ enabledByDefault: input.enabledByDefaultModuleIds.has(module.manifest.id),
39
+ });
40
+ }
41
+ }
42
+ function createPostgresqlGuildTrackingPersistence(database) {
43
+ const repository = new InstallationRepository(database);
44
+ return {
45
+ engine: "postgresql",
46
+ async syncGuild(input) {
47
+ const persist = (connection) => writeGuildObservation(new InstallationRepository(connection), new GuildCommandRepository(connection), input);
48
+ if (input.firstObservation)
49
+ await withTransaction(database, persist);
50
+ else
51
+ await persist(database);
52
+ },
53
+ async markGuildRemoved(guildId, ownership, applicationId) {
54
+ const changed = await repository.markGuildRemoved(guildId, ownership);
55
+ if (changed && applicationId)
56
+ await new GuildCommandRepository(database).cancelGuildWork(applicationId, guildId);
57
+ return changed;
58
+ },
59
+ listActiveGuildIdsForOwnership: (input) => repository.listActiveGuildIdsForOwnership(input),
60
+ adoptGuildsForReconciliation: (input) => repository.adoptGuildsForReconciliation(input),
61
+ markGuildsRemovedForReconciliation: (input) => repository.markGuildsRemovedForReconciliation(input),
62
+ };
63
+ }
64
+ export async function createMysqlGuildTrackingPersistence(database) {
65
+ const { InstallationRepository: MysqlInstallationRepository, GuildCommandRepository: MysqlGuildCommandRepository, createMysqlQueryExecutor, withMysqlTransaction, } = await import("@helyx/database-mysql");
66
+ const repository = new MysqlInstallationRepository(database);
67
+ return {
68
+ engine: "mysql",
69
+ async syncGuild(input) {
70
+ if (input.ownership)
71
+ throw new Error("MySQL guild tracking does not support shard ownership");
72
+ if (input.firstObservation) {
73
+ await withMysqlTransaction(database, (connection) => {
74
+ const executor = createMysqlQueryExecutor(connection);
75
+ return writeGuildObservation(new MysqlInstallationRepository(executor), new MysqlGuildCommandRepository(executor), input);
76
+ });
77
+ }
78
+ else {
79
+ await writeGuildObservation(new MysqlInstallationRepository(database), new MysqlGuildCommandRepository(database), input);
80
+ }
81
+ },
82
+ async markGuildRemoved(guildId, ownership, applicationId) {
83
+ if (ownership)
84
+ throw new Error("MySQL guild tracking does not support shard ownership");
85
+ return withMysqlTransaction(database, async (connection) => {
86
+ const executor = createMysqlQueryExecutor(connection);
87
+ const changed = await new MysqlInstallationRepository(executor).markGuildRemoved(guildId);
88
+ if (changed && applicationId)
89
+ await new MysqlGuildCommandRepository(executor).cancelGuildWork(applicationId, guildId);
90
+ return changed;
91
+ });
92
+ },
93
+ listActiveGuildIdsForOwnership: (input) => {
94
+ if (input.ownership)
95
+ throw new Error("MySQL guild tracking does not support shard ownership");
96
+ return repository.listActiveGuildIdsForOwnership(input);
97
+ },
98
+ adoptGuildsForReconciliation: () => {
99
+ throw new Error("MySQL guild tracking does not support shard ownership");
100
+ },
101
+ markGuildsRemovedForReconciliation: (input) => {
102
+ if (input.ownership)
103
+ throw new Error("MySQL guild tracking does not support shard ownership");
104
+ return repository.markGuildsRemovedForReconciliation(input);
105
+ },
106
+ };
107
+ }
6
108
  export function installGuildTracking(client, database, modules, logger, enabledByDefaultModuleIds = new Set(), ownership, reconciliationIntervalMs = DEFAULT_RECONCILIATION_INTERVAL_MS, shardCount, applicationId, hostedSafety, hostedInviteGate, hostedOwnerObserver = hostedSafety) {
7
109
  if (!Number.isSafeInteger(reconciliationIntervalMs) ||
8
110
  reconciliationIntervalMs < 60_000)
9
111
  throw new Error("Guild reconciliation interval must be at least one minute");
10
- const repository = new InstallationRepository(database);
112
+ const persistence = "engine" in database
113
+ ? database
114
+ : createPostgresqlGuildTrackingPersistence(database);
115
+ if (persistence.engine === "mysql" && ownership)
116
+ throw new Error("MySQL guild tracking does not support shard ownership");
11
117
  let prepared = false;
12
118
  let stopped = false;
13
119
  let reconciliation;
120
+ let stopPromise;
121
+ const inFlightOperations = new Set();
14
122
  let timer;
15
123
  const admissionRetries = new Map();
16
124
  const inviteGateRejections = new Map();
@@ -25,46 +133,29 @@ export function installGuildTracking(client, database, modules, logger, enabledB
25
133
  rejectedFences: 0,
26
134
  unavailableGuilds: 0,
27
135
  };
136
+ function trackOperation(operation) {
137
+ const settled = operation.then(() => undefined, () => undefined);
138
+ inFlightOperations.add(settled);
139
+ void settled.then(() => inFlightOperations.delete(settled));
140
+ return operation;
141
+ }
28
142
  async function syncGuild(guild, firstObservation = false) {
29
143
  const cachedOwner = guild.members?.cache.get(guild.ownerId)?.user ??
30
144
  guild.client?.users.cache.get(guild.ownerId);
31
- const persist = async (connection) => {
32
- const installations = new InstallationRepository(connection);
33
- const record = await installations.syncGuild({
34
- guildId: guild.id,
35
- ownerUserId: guild.ownerId,
36
- ...(cachedOwner ? { ownerUsername: cachedOwner.username } : {}),
37
- name: guild.name,
38
- ...(ownership ? { ownership } : {}),
39
- });
40
- if (ownership &&
41
- (record.shardGeneration !== ownership.generation ||
42
- record.shardDeploymentOrder !== (ownership.deploymentOrder ?? 1n) ||
43
- record.shardSupervisorId !== ownership.supervisorId ||
44
- record.shardId !== ownership.shardId ||
45
- record.shardFencingToken !== ownership.fencingToken))
46
- throw new Error("Guild observation was rejected by a newer shard owner");
47
- if (firstObservation && applicationId)
48
- await new GuildCommandRepository(connection).ensureState({
49
- applicationId,
50
- guildId: guild.id,
51
- setupState: "incomplete",
52
- });
53
- for (const module of modules) {
54
- await installations.ensureModule({
55
- guildId: guild.id,
56
- moduleId: module.manifest.id,
57
- moduleVersion: module.manifest.version,
58
- configurationSchemaVersion: module.configuration?.schemaVersion ?? 1,
59
- enabledBy: guild.ownerId,
60
- enabledByDefault: enabledByDefaultModuleIds.has(module.manifest.id),
61
- });
62
- }
63
- };
64
- if (firstObservation)
65
- await withTransaction(database, persist);
66
- else
67
- await persist(database);
145
+ await persistence.syncGuild({
146
+ guildId: guild.id,
147
+ ownerUserId: guild.ownerId,
148
+ ...(cachedOwner ? { ownerUsername: cachedOwner.username } : {}),
149
+ name: guild.name,
150
+ modules,
151
+ enabledByDefaultModuleIds,
152
+ firstObservation,
153
+ continueObservation: () => !stopped,
154
+ ...(applicationId ? { applicationId } : {}),
155
+ ...(ownership ? { ownership } : {}),
156
+ });
157
+ if (stopped)
158
+ return;
68
159
  if (cachedOwner && hostedOwnerObserver)
69
160
  await hostedOwnerObserver.observeOwner(guild);
70
161
  else
@@ -82,6 +173,8 @@ export function installGuildTracking(client, database, modules, logger, enabledB
82
173
  }, "New guild was rejected by the Bot Invite gate");
83
174
  }
84
175
  async function admitAndSync(guild, firstObservation) {
176
+ if (stopped)
177
+ return;
85
178
  if (inviteGateRejections.has(guild.id)) {
86
179
  await leaveInviteRejectedGuild(guild);
87
180
  return;
@@ -92,6 +185,8 @@ export function installGuildTracking(client, database, modules, logger, enabledB
92
185
  : Promise.resolve(false),
93
186
  hostedInviteGate ? hostedInviteGate.isEnabled() : Promise.resolve(true),
94
187
  ]);
188
+ if (stopped)
189
+ return;
95
190
  if (hostedSafety && blacklist.status === "fulfilled" && blacklist.value) {
96
191
  const enforcement = await Promise.allSettled([
97
192
  hostedSafety.recordAutomaticLeave(guild.id),
@@ -132,7 +227,7 @@ export function installGuildTracking(client, database, modules, logger, enabledB
132
227
  admissionRetries.delete(guild.id);
133
228
  if (stopped || !guild.available)
134
229
  return;
135
- void admitAndSync(guild, true).catch(() => {
230
+ void trackOperation(admitAndSync(guild, true)).catch(() => {
136
231
  logger.error({
137
232
  event: "guild.admission_retry_failed",
138
233
  guildId: guild.id,
@@ -145,7 +240,9 @@ export function installGuildTracking(client, database, modules, logger, enabledB
145
240
  admissionRetries.set(guild.id, retry);
146
241
  }
147
242
  client.on(Events.GuildCreate, (guild) => {
148
- void admitAndSync(guild, true).catch(() => {
243
+ if (stopped)
244
+ return;
245
+ void trackOperation(admitAndSync(guild, true)).catch(() => {
149
246
  logger.error({
150
247
  event: "guild.admission_failed",
151
248
  guildId: guild.id,
@@ -155,20 +252,21 @@ export function installGuildTracking(client, database, modules, logger, enabledB
155
252
  });
156
253
  });
157
254
  client.on(Events.GuildUpdate, (_previous, guild) => {
255
+ if (stopped)
256
+ return;
158
257
  if (!inviteGateRejections.has(guild.id))
159
258
  ownerObservations?.enqueue(guild);
160
259
  });
161
260
  client.on(Events.GuildDelete, (guild) => {
261
+ if (stopped)
262
+ return;
162
263
  const retry = admissionRetries.get(guild.id);
163
264
  if (retry)
164
265
  clearTimeout(retry);
165
266
  admissionRetries.delete(guild.id);
166
267
  inviteGateRejections.delete(guild.id);
167
- void repository
168
- .markGuildRemoved(guild.id, ownership)
169
- .then(async (changed) => {
170
- if (changed && applicationId)
171
- await new GuildCommandRepository(database).cancelGuildWork(applicationId, guild.id);
268
+ void trackOperation(persistence.markGuildRemoved(guild.id, ownership, applicationId))
269
+ .then((changed) => {
172
270
  logger.info({
173
271
  event: "guild.removed",
174
272
  outcome: changed ? "changed" : "rejected_or_duplicate",
@@ -210,6 +308,8 @@ export function installGuildTracking(client, database, modules, logger, enabledB
210
308
  if (hostedSafety) {
211
309
  const observed = [...observedGuildIds];
212
310
  for (let offset = 0; offset < observed.length; offset += RECONCILIATION_PAGE_SIZE) {
311
+ if (stopped)
312
+ return;
213
313
  const page = await hostedSafety.blacklistedGuildIds(observed.slice(offset, offset + RECONCILIATION_PAGE_SIZE));
214
314
  for (const guildId of page)
215
315
  blacklistedGuildIds.add(guildId);
@@ -224,30 +324,38 @@ export function installGuildTracking(client, database, modules, logger, enabledB
224
324
  continue;
225
325
  }
226
326
  await syncGuild(guild);
327
+ if (stopped)
328
+ return;
227
329
  refreshedGuilds += 1;
228
330
  }
229
331
  let cursor;
230
332
  do {
231
333
  if (stopped || !client.isReady())
232
334
  return;
233
- const page = await repository.listActiveGuildIdsForOwnership({
335
+ const page = await persistence.listActiveGuildIdsForOwnership({
234
336
  ...(ownership ? { ownership } : {}),
235
337
  ...(ownership && shardCount !== undefined ? { shardCount } : {}),
236
338
  ...(cursor ? { cursor } : {}),
237
339
  limit: RECONCILIATION_PAGE_SIZE,
238
340
  });
341
+ if (stopped)
342
+ return;
239
343
  examinedRows += page.guildIds.length;
240
344
  if (ownership)
241
- await repository.adoptGuildsForReconciliation({
345
+ await persistence.adoptGuildsForReconciliation({
242
346
  guildIds: page.guildIds,
243
347
  ownership,
244
348
  shardCount: shardCount,
245
349
  });
350
+ if (stopped)
351
+ return;
246
352
  const absent = page.guildIds.filter((guildId) => !observedGuildIds.has(guildId));
247
- const result = await repository.markGuildsRemovedForReconciliation({
353
+ const result = await persistence.markGuildsRemovedForReconciliation({
248
354
  guildIds: absent,
249
355
  ...(ownership ? { ownership } : {}),
250
356
  });
357
+ if (stopped)
358
+ return;
251
359
  removedGuilds += result.changed;
252
360
  rejectedFences += result.rejectedFence;
253
361
  cursor = page.nextCursor;
@@ -277,22 +385,23 @@ export function installGuildTracking(client, database, modules, logger, enabledB
277
385
  }, "Discord guild presence reconciliation completed");
278
386
  }
279
387
  catch (error) {
280
- health = {
281
- status: "failed",
282
- lastStartedAt: new Date(startedAt).toISOString(),
283
- durationMs: Date.now() - startedAt,
284
- examinedRows,
285
- refreshedGuilds,
286
- removedGuilds,
287
- rejectedFences,
288
- unavailableGuilds,
289
- failureReasonCode: "reconciliation_failed",
290
- nextScheduledAt: new Date(Date.now() + reconciliationIntervalMs).toISOString(),
291
- };
388
+ if (!stopped)
389
+ health = {
390
+ status: "failed",
391
+ lastStartedAt: new Date(startedAt).toISOString(),
392
+ durationMs: Date.now() - startedAt,
393
+ examinedRows,
394
+ refreshedGuilds,
395
+ removedGuilds,
396
+ rejectedFences,
397
+ unavailableGuilds,
398
+ failureReasonCode: "reconciliation_failed",
399
+ nextScheduledAt: new Date(Date.now() + reconciliationIntervalMs).toISOString(),
400
+ };
292
401
  logger.error({
293
402
  event: "guild.reconciliation_failed",
294
403
  reason,
295
- reasonCode: health.failureReasonCode,
404
+ reasonCode: "reconciliation_failed",
296
405
  error: safeError(error),
297
406
  }, "Discord guild presence reconciliation failed");
298
407
  }
@@ -306,7 +415,7 @@ export function installGuildTracking(client, database, modules, logger, enabledB
306
415
  return;
307
416
  prepared = true;
308
417
  await runReconciliation("ready");
309
- if (!timer) {
418
+ if (!stopped && !timer) {
310
419
  timer = setInterval(() => void runReconciliation("periodic"), reconciliationIntervalMs);
311
420
  timer.unref();
312
421
  }
@@ -317,6 +426,8 @@ export function installGuildTracking(client, database, modules, logger, enabledB
317
426
  prepare.reconcile = runReconciliation;
318
427
  prepare.health = () => ({ ...health });
319
428
  prepare.stop = () => {
429
+ if (stopPromise)
430
+ return stopPromise;
320
431
  stopped = true;
321
432
  if (timer)
322
433
  clearInterval(timer);
@@ -325,8 +436,14 @@ export function installGuildTracking(client, database, modules, logger, enabledB
325
436
  clearTimeout(retry);
326
437
  admissionRetries.clear();
327
438
  inviteGateRejections.clear();
328
- ownerObservations?.stop();
439
+ const ownerDrain = ownerObservations?.stop();
329
440
  health = { ...health, status: "stopped", nextScheduledAt: undefined };
441
+ stopPromise = Promise.allSettled([
442
+ ...inFlightOperations,
443
+ ...(reconciliation ? [reconciliation] : []),
444
+ ...(ownerDrain ? [ownerDrain] : []),
445
+ ]).then(() => undefined);
446
+ return stopPromise;
330
447
  };
331
448
  return prepare;
332
449
  }
@@ -336,6 +453,7 @@ class OwnerObservationQueue {
336
453
  #pending = new Map();
337
454
  #active = 0;
338
455
  #stopped = false;
456
+ #inFlight = new Set();
339
457
  constructor(observer, logger) {
340
458
  this.observer = observer;
341
459
  this.logger = logger;
@@ -349,6 +467,7 @@ class OwnerObservationQueue {
349
467
  stop() {
350
468
  this.#stopped = true;
351
469
  this.#pending.clear();
470
+ return Promise.allSettled([...this.#inFlight]).then(() => undefined);
352
471
  }
353
472
  #drain() {
354
473
  while (!this.#stopped && this.#active < 2 && this.#pending.size > 0) {
@@ -358,7 +477,7 @@ class OwnerObservationQueue {
358
477
  const [guildId, guild] = next;
359
478
  this.#pending.delete(guildId);
360
479
  this.#active += 1;
361
- void this.observer
480
+ const observation = this.observer
362
481
  .observeOwner(guild)
363
482
  .catch(() => {
364
483
  this.logger.warn({
@@ -368,9 +487,11 @@ class OwnerObservationQueue {
368
487
  }, "Guild owner identity observation was deferred");
369
488
  })
370
489
  .finally(() => {
490
+ this.#inFlight.delete(observation);
371
491
  this.#active -= 1;
372
492
  this.#drain();
373
493
  });
494
+ this.#inFlight.add(observation);
374
495
  }
375
496
  }
376
497
  }
@@ -11,8 +11,8 @@ declare const resultSchema: z.ZodObject<{
11
11
  }>;
12
12
  outcome: z.ZodEnum<{
13
13
  completed: "completed";
14
- requires_review: "requires_review";
15
14
  failed: "failed";
15
+ requires_review: "requires_review";
16
16
  queued: "queued";
17
17
  not_currently_installed: "not_currently_installed";
18
18
  accepted_for_retry: "accepted_for_retry";
@@ -45,7 +45,7 @@ export declare class HostedServerModerationClient implements DeveloperServerMode
45
45
  idempotencyKey: string;
46
46
  }): Promise<{
47
47
  operationId: string;
48
- outcome: "completed" | "requires_review" | "failed" | "queued" | "not_currently_installed" | "accepted_for_retry" | "already_blacklisted";
48
+ outcome: "completed" | "failed" | "requires_review" | "queued" | "not_currently_installed" | "accepted_for_retry" | "already_blacklisted";
49
49
  paidSlotsReleased: number;
50
50
  }>;
51
51
  }
@@ -168,14 +168,20 @@ export class HostedServerLeaveWorker {
168
168
  });
169
169
  }
170
170
  async #deliver(claim) {
171
+ const outcome = await this.#repository.withLeaveDeliveryGuard(claim, (connection) => this.#deliverGuarded(claim, connection));
172
+ return (outcome ??
173
+ (await this.#repository.getLeaveDeliveryOutcome(claim.operationId, claim.guildId)) ??
174
+ "queued");
175
+ }
176
+ async #deliverGuarded(claim, connection) {
171
177
  const guild = this.client.guilds.cache.get(claim.guildId);
172
178
  if (!guild) {
173
- await this.#repository.completeLeaveDelivery(claim, "not_currently_installed");
179
+ await this.#repository.completeLeaveDelivery(claim, "not_currently_installed", connection);
174
180
  return "not_currently_installed";
175
181
  }
176
182
  try {
177
183
  await guild.leave();
178
- await this.#repository.completeLeaveDelivery(claim, "completed");
184
+ await this.#repository.completeLeaveDelivery(claim, "completed", connection);
179
185
  this.logger.info({
180
186
  event: "hosted_moderation.leave_completed",
181
187
  guildId: claim.guildId,
@@ -186,14 +192,14 @@ export class HostedServerLeaveWorker {
186
192
  catch (error) {
187
193
  const failureCode = classifyLeaveFailure(error);
188
194
  if (failureCode === "discord_not_installed") {
189
- await this.#repository.completeLeaveDelivery(claim, "not_currently_installed");
195
+ await this.#repository.completeLeaveDelivery(claim, "not_currently_installed", connection);
190
196
  return "not_currently_installed";
191
197
  }
192
198
  const outcome = await this.#repository.failLeaveDelivery({
193
199
  claim,
194
200
  failureCode,
195
201
  retryAfterSeconds: Math.min(3_600, 15 * 2 ** claim.attemptCount),
196
- });
202
+ }, connection);
197
203
  this.logger.warn({
198
204
  event: "hosted_moderation.leave_deferred",
199
205
  guildId: claim.guildId,
package/dist/index.d.ts CHANGED
@@ -2,6 +2,7 @@ import { type ModuleDefinition } from "@helyx/sdk";
2
2
  import { GatewayIntentBits } from "discord.js";
3
3
  import { type ShardHealthSnapshot } from "./shard-contracts.js";
4
4
  export { loadConfiguredModulePackages } from "./modules.js";
5
+ export { assertConfiguredModuleDatabaseCompatibility } from "./modules.js";
5
6
  export { resolveModulePackageSpecifiers } from "./module-configuration.js";
6
7
  export * from "./control-server.js";
7
8
  export interface BotHandle {