@adonis-agora/authkit-server 0.81.3 → 0.82.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.
package/README.md CHANGED
@@ -120,7 +120,7 @@ router.post('/auth/interaction/:uid/consent', [AuthInteractionController, 'conse
120
120
 
121
121
  Escolha o backend no `config/authkit.ts`:
122
122
  - `adapters.redis({ connection })` — requer `@adonisjs/redis` configurado.
123
- - `adapters.database({ connection? })` — Lucid; rode a migração `authkit_oidc_payloads`.
123
+ - `adapters.database({ connection? })` — Lucid; tabela `authkit_oidc_payloads`, criada pelo `ensureAuthkitSchema` no boot (com `schema: { autoManage: false }`, chame-o numa migração). A coluna indexada `account_id` mantém as buscas e revogações por conta em SQL; linhas antigas com `account_id` NULL continuam sendo encontradas. Postgres, MySQL e SQLite.
124
124
 
125
125
  ## Observabilidade
126
126
 
@@ -11,6 +11,9 @@ export default class extends BaseSchema {
11
11
  table.string('uid', 255).nullable().index();
12
12
  table.timestamp('expires_at', { useTz: true }).nullable();
13
13
  table.primary(['model_name', 'id']);
14
+ table.string('account_id', 255).nullable();
15
+ table.index(['model_name', 'account_id'], 'authkit_oidc_payloads_account_idx');
16
+ table.index(['expires_at'], 'authkit_oidc_payloads_expires_idx');
14
17
  });
15
18
  }
16
19
  async down() {
@@ -36,4 +36,16 @@ export interface OidcAdapter {
36
36
  * (`new AdapterClass(model)`), então não recebe parâmetro.
37
37
  */
38
38
  list?(): Promise<EnumeratedArtifact[]>;
39
+ /**
40
+ * Artefatos vivos deste model cujo `payload.accountId` é `accountId` — o mesmo
41
+ * resultado de `list()` filtrado pela conta, sem enumerar as outras contas.
42
+ * OPCIONAL: ausente, os consumidores caem em `list()` + filtro. Tem de ser completo
43
+ * (a revogação confia nele): um artefato que fique de fora continua valendo.
44
+ */
45
+ listByAccount?(accountId: string): Promise<EnumeratedArtifact[]>;
46
+ /**
47
+ * Artefatos vivos deste model cujo `payload.grantId` está em `grantIds` — o
48
+ * mesmo resultado de `list()` filtrado pelos grants. OPCIONAL, como `listByAccount`.
49
+ */
50
+ listByGrantIds?(grantIds: string[]): Promise<EnumeratedArtifact[]>;
39
51
  }
@@ -19,4 +19,14 @@ export declare class DatabaseAdapter implements OidcAdapter {
19
19
  * console admin para listar `Client` (CRUD) e `Session`/`Grant`/tokens.
20
20
  */
21
21
  list(): Promise<EnumeratedArtifact[]>;
22
+ /**
23
+ * Artefatos vivos da conta, pelo índice `(model_name, account_id)`. As linhas com
24
+ * `account_id` NULL (gravadas antes da coluna existir, ou por uma versão anterior
25
+ * da lib durante um deploy) entram na leitura; o filtro final é sempre o
26
+ * `payload.accountId`, e as NULL que têm conta no payload são classificadas aqui,
27
+ * para não serem relidas na próxima busca.
28
+ */
29
+ listByAccount(accountId: string): Promise<EnumeratedArtifact[]>;
30
+ /** Artefatos vivos que referenciam algum dos grants, pela coluna indexada `grant_id`. */
31
+ listByGrantIds(grantIds: string[]): Promise<EnumeratedArtifact[]>;
22
32
  }
@@ -1,4 +1,31 @@
1
1
  const TABLE = 'authkit_oidc_payloads';
2
+ /** Intervalo mínimo, por conexão, entre duas limpezas das linhas expiradas. */
3
+ const PRUNE_INTERVAL_MS = 10 * 60_000;
4
+ /** Quanto tempo a ausência da coluna `account_id` vale antes de re-checar. */
5
+ const ACCOUNT_COLUMN_RECHECK_MS = 60_000;
6
+ /** Tamanho máximo de uma lista `IN (...)`. */
7
+ const IN_CHUNK = 500;
8
+ const connectionStates = new WeakMap();
9
+ function connectionState(db) {
10
+ let state = connectionStates.get(db);
11
+ if (!state) {
12
+ state = { accountColumn: null, accountColumnRecheckAt: 0, nextPruneAt: 0 };
13
+ connectionStates.set(db, state);
14
+ }
15
+ return state;
16
+ }
17
+ /** Dialetos que gravam `expires_at` como texto ISO-8601 (comparado lexicograficamente). */
18
+ const TEXT_TIMESTAMP_DIALECTS = new Set([
19
+ 'sqlite3',
20
+ 'better-sqlite3',
21
+ 'libsql',
22
+ ]);
23
+ function chunks(items) {
24
+ const out = [];
25
+ for (let i = 0; i < items.length; i += IN_CHUNK)
26
+ out.push(items.slice(i, i + IN_CHUNK));
27
+ return out;
28
+ }
2
29
  export class DatabaseAdapter {
3
30
  name;
4
31
  db;
@@ -9,10 +36,74 @@ export class DatabaseAdapter {
9
36
  #query() {
10
37
  return this.db.query().from(TABLE).where('model_name', this.name);
11
38
  }
39
+ /**
40
+ * Valor de `expires_at` (gravação e comparação) para o dialeto da conexão. No
41
+ * SQLite a coluna guarda texto ISO-8601 (o driver gravaria um `Date` como número,
42
+ * que não se compara com o texto já gravado); nos demais vai um `Date`, que o
43
+ * driver serializa no formato do banco — o MySQL recusa o ISO com `Z`.
44
+ */
45
+ #timestamp(ms) {
46
+ const state = connectionState(this.db);
47
+ if (state.dialect === undefined) {
48
+ // O factory passa o manager (`Database`) ou um `QueryClient` de conexão nomeada.
49
+ const db = this.db;
50
+ const client = typeof db.connection === 'function' ? db.connection() : db;
51
+ state.dialect = client?.dialect?.name ?? null;
52
+ }
53
+ const dialect = state.dialect;
54
+ return !dialect || TEXT_TIMESTAMP_DIALECTS.has(dialect)
55
+ ? new Date(ms).toISOString()
56
+ : new Date(ms);
57
+ }
58
+ /** `#query()` restrito às linhas não expiradas. */
59
+ #liveQuery() {
60
+ const now = this.#timestamp(Date.now());
61
+ return this.#query().where((q) => q.whereNull('expires_at').orWhere('expires_at', '>', now));
62
+ }
63
+ /**
64
+ * A coluna `account_id` existe? Probe por SELECT (searchPath-aware, como o
65
+ * `ensureAuthkitSchema`). "Presente" vale pela vida do processo; "ausente" é
66
+ * re-checado, para pegar o ALTER do boot que rodou depois do primeiro request.
67
+ */
68
+ async #hasAccountColumn() {
69
+ const state = connectionState(this.db);
70
+ if (state.accountColumn === true)
71
+ return true;
72
+ if (state.accountColumn === false && state.accountColumnRecheckAt > Date.now())
73
+ return false;
74
+ try {
75
+ await this.db.query().from(TABLE).select('account_id').limit(1);
76
+ state.accountColumn = true;
77
+ }
78
+ catch {
79
+ state.accountColumn = false;
80
+ state.accountColumnRecheckAt = Date.now() + ACCOUNT_COLUMN_RECHECK_MS;
81
+ }
82
+ return state.accountColumn;
83
+ }
84
+ /**
85
+ * Apaga as linhas expiradas de todos os models (no máximo uma vez por
86
+ * {@link PRUNE_INTERVAL_MS} por conexão). Linha expirada já é invisível para
87
+ * `find`/`list`; a limpeza só impede a tabela de crescer sem limite. Roda fora
88
+ * do caminho do request e nunca lança.
89
+ */
90
+ #maybePrune() {
91
+ const state = connectionState(this.db);
92
+ const now = Date.now();
93
+ if (state.nextPruneAt > now)
94
+ return;
95
+ state.nextPruneAt = now + PRUNE_INTERVAL_MS;
96
+ Promise.resolve()
97
+ .then(() => this.db
98
+ .query()
99
+ .from(TABLE)
100
+ .whereNotNull('expires_at')
101
+ .where('expires_at', '<=', this.#timestamp(now))
102
+ .delete())
103
+ .catch(() => { });
104
+ }
12
105
  async upsert(id, payload, expiresIn) {
13
- // Armazenamos como ISO string para que o parse de expiração seja determinístico
14
- // independente do backend (SQLite/Postgres) e do formato nativo de timestamp.
15
- const expiresAt = expiresIn ? new Date(Date.now() + expiresIn * 1000).toISOString() : null;
106
+ const expiresAt = expiresIn ? this.#timestamp(Date.now() + expiresIn * 1000) : null;
16
107
  const row = {
17
108
  id,
18
109
  model_name: this.name,
@@ -22,6 +113,12 @@ export class DatabaseAdapter {
22
113
  uid: payload.uid ?? null,
23
114
  expires_at: expiresAt,
24
115
  };
116
+ if (await this.#hasAccountColumn()) {
117
+ // NULL = "sem conta conhecida": as buscas por conta também leem essas linhas
118
+ // (e as classificam), então uma linha escrita sem a coluna nunca fica de fora.
119
+ const accountId = payload.accountId;
120
+ row.account_id = typeof accountId === 'string' && accountId ? accountId : null;
121
+ }
25
122
  const existing = await this.#query().where('id', id).first();
26
123
  if (existing) {
27
124
  await this.#query().where('id', id).update(row);
@@ -29,6 +126,7 @@ export class DatabaseAdapter {
29
126
  else {
30
127
  await this.db.table(TABLE).insert(row);
31
128
  }
129
+ this.#maybePrune();
32
130
  }
33
131
  async #parse(record) {
34
132
  if (!record)
@@ -53,10 +151,10 @@ export class DatabaseAdapter {
53
151
  const changed = await this.#query()
54
152
  .where('id', id)
55
153
  .where('payload', row.payload)
56
- .where('expires_at', '>', new Date(now).toISOString())
154
+ .where('expires_at', '>', this.#timestamp(now))
57
155
  .update({
58
156
  payload: JSON.stringify({ ...payload, exp: Math.floor(now / 1000) + expiresIn }),
59
- expires_at: new Date(now + expiresIn * 1000).toISOString(),
157
+ expires_at: this.#timestamp(now + expiresIn * 1000),
60
158
  });
61
159
  if (Number(changed) > 0)
62
160
  return true;
@@ -94,14 +192,81 @@ export class DatabaseAdapter {
94
192
  * console admin para listar `Client` (CRUD) e `Session`/`Grant`/tokens.
95
193
  */
96
194
  async list() {
97
- const rows = await this.#query().orderBy('id', 'asc');
195
+ return this.#toArtifacts(await this.#liveQuery().orderBy('id', 'asc'));
196
+ }
197
+ /**
198
+ * Artefatos vivos da conta, pelo índice `(model_name, account_id)`. As linhas com
199
+ * `account_id` NULL (gravadas antes da coluna existir, ou por uma versão anterior
200
+ * da lib durante um deploy) entram na leitura; o filtro final é sempre o
201
+ * `payload.accountId`, e as NULL que têm conta no payload são classificadas aqui,
202
+ * para não serem relidas na próxima busca.
203
+ */
204
+ async listByAccount(accountId) {
205
+ if (!(await this.#hasAccountColumn())) {
206
+ return (await this.list()).filter((r) => r.payload.accountId === accountId);
207
+ }
208
+ const rows = await this.#liveQuery()
209
+ .where((q) => q.where('account_id', accountId).orWhereNull('account_id'))
210
+ .orderBy('id', 'asc');
211
+ const unclassified = new Map();
212
+ const result = [];
213
+ for (const artifact of this.#parseRows(rows)) {
214
+ const owner = artifact.payload.accountId;
215
+ if (artifact.unclassified && typeof owner === 'string' && owner) {
216
+ const ids = unclassified.get(owner) ?? [];
217
+ ids.push(artifact.id);
218
+ unclassified.set(owner, ids);
219
+ }
220
+ if (owner === accountId)
221
+ result.push({ id: artifact.id, payload: artifact.payload });
222
+ }
223
+ await this.#classify(unclassified);
224
+ return result;
225
+ }
226
+ /** Artefatos vivos que referenciam algum dos grants, pela coluna indexada `grant_id`. */
227
+ async listByGrantIds(grantIds) {
228
+ const wanted = new Set(grantIds);
229
+ const result = [];
230
+ for (const chunk of chunks([...wanted])) {
231
+ const rows = await this.#liveQuery().whereIn('grant_id', chunk).orderBy('id', 'asc');
232
+ for (const artifact of this.#toArtifacts(rows)) {
233
+ if (wanted.has(artifact.payload.grantId))
234
+ result.push(artifact);
235
+ }
236
+ }
237
+ return result;
238
+ }
239
+ async #classify(byAccount) {
240
+ for (const [accountId, ids] of byAccount) {
241
+ for (const chunk of chunks(ids)) {
242
+ try {
243
+ await this.#query()
244
+ .whereNull('account_id')
245
+ .whereIn('id', chunk)
246
+ .update({ account_id: accountId });
247
+ }
248
+ catch {
249
+ // Classificar é só otimização: a linha segue NULL e é lida de novo.
250
+ }
251
+ }
252
+ }
253
+ }
254
+ /** Linhas → artefatos, descartando as expiradas; `unclassified` = `account_id` NULL. */
255
+ #parseRows(rows) {
98
256
  const now = Date.now();
99
257
  const result = [];
100
258
  for (const row of rows) {
101
259
  if (row.expires_at && new Date(row.expires_at).getTime() <= now)
102
260
  continue;
103
- result.push({ id: row.id, payload: JSON.parse(row.payload) });
261
+ result.push({
262
+ id: row.id,
263
+ payload: JSON.parse(row.payload),
264
+ unclassified: row.account_id == null,
265
+ });
104
266
  }
105
267
  return result;
106
268
  }
269
+ #toArtifacts(rows) {
270
+ return this.#parseRows(rows).map(({ id, payload }) => ({ id, payload }));
271
+ }
107
272
  }
@@ -19,7 +19,7 @@ export declare class RedisAdapter implements OidcAdapter {
19
19
  * prefixo de chave do model (`<prefix>:<name>:*`). É limpo porque cada artefato
20
20
  * é uma chave única já namespaceada por `prefix` + `name`; SCAN é não-bloqueante
21
21
  * (cursor) ao contrário de KEYS. Usado pelo console admin (`Client`, `Session`,
22
- * `Grant`, tokens).
22
+ * `Grant`, tokens). Cada página do SCAN é lida num único MGET.
23
23
  */
24
24
  list(): Promise<EnumeratedArtifact[]>;
25
25
  }
@@ -128,24 +128,27 @@ export class RedisAdapter {
128
128
  * prefixo de chave do model (`<prefix>:<name>:*`). É limpo porque cada artefato
129
129
  * é uma chave única já namespaceada por `prefix` + `name`; SCAN é não-bloqueante
130
130
  * (cursor) ao contrário de KEYS. Usado pelo console admin (`Client`, `Session`,
131
- * `Grant`, tokens).
131
+ * `Grant`, tokens). Cada página do SCAN é lida num único MGET.
132
132
  */
133
133
  async list() {
134
134
  const prefix = `${this.prefix}:${this.name}:`;
135
135
  const result = [];
136
136
  let cursor = '0';
137
137
  do {
138
- const [next, keys] = await this.redis.scan(cursor, 'MATCH', `${prefix}*`, 'COUNT', 100);
138
+ const [next, keys] = await this.redis.scan(cursor, 'MATCH', `${prefix}*`, 'COUNT', 1000);
139
139
  cursor = next;
140
- for (const key of keys) {
141
- const data = await this.redis.get(key);
140
+ if (keys.length === 0)
141
+ continue;
142
+ const values = await this.redis.mget(...keys);
143
+ keys.forEach((key, i) => {
144
+ const data = values[i];
142
145
  if (!data)
143
- continue;
146
+ return;
144
147
  result.push({
145
148
  id: key.slice(prefix.length),
146
149
  payload: JSON.parse(data),
147
150
  });
148
- }
151
+ });
149
152
  } while (cursor !== '0');
150
153
  result.sort((a, b) => a.id.localeCompare(b.id));
151
154
  return result;
@@ -122,6 +122,32 @@ export class AdminSessionsService {
122
122
  return [];
123
123
  return adapter.list();
124
124
  }
125
+ /** Artefatos do model da conta — pela busca por conta do adapter, quando existe. */
126
+ async #listModelOfAccount(model, accountId) {
127
+ const adapter = this.#adapter(model);
128
+ if (adapter.listByAccount)
129
+ return adapter.listByAccount(accountId);
130
+ if (!adapter.list)
131
+ return [];
132
+ return (await adapter.list()).filter((r) => r.payload.accountId === accountId);
133
+ }
134
+ /** Artefatos de um model token que referenciam algum dos grants. */
135
+ async #listTokensOfGrants(model, grantIds) {
136
+ if (grantIds.size === 0)
137
+ return [];
138
+ const adapter = this.#adapter(model);
139
+ if (adapter.listByGrantIds)
140
+ return adapter.listByGrantIds([...grantIds]);
141
+ if (!adapter.list)
142
+ return [];
143
+ return (await adapter.list()).filter((r) => grantIds.has(r.payload.grantId));
144
+ }
145
+ /** Grants da conta, exceto os espelhos `pgrant_` de Account Tokens do Poppy. */
146
+ async #accountGrants(accountId) {
147
+ // Os grants `pgrant_` são o espelho de um Account Token do Poppy (Bearer de MCP) — aparecem e
148
+ // são revogados como personal agent, não como app OIDC.
149
+ return (await this.#listModelOfAccount('Grant', accountId)).filter((r) => !r.id.startsWith('pgrant_'));
150
+ }
125
151
  /**
126
152
  * Conta TODAS as sessões ativas do IdP (todas as contas). 0 quando o adapter não
127
153
  * enumera. Usado pelo dashboard de métricas.
@@ -132,10 +158,8 @@ export class AdminSessionsService {
132
158
  }
133
159
  /** Lista as sessões ativas da conta (vazio quando o adapter não enumera). */
134
160
  async listSessions(accountId) {
135
- const rows = await this.#listModel('Session');
136
- return rows
137
- .filter((r) => r.payload.accountId === accountId)
138
- .map((r) => ({
161
+ const rows = await this.#listModelOfAccount('Session', accountId);
162
+ return rows.map((r) => ({
139
163
  id: r.id,
140
164
  accountId,
141
165
  loginTs: r.payload.loginTs,
@@ -183,14 +207,12 @@ export class AdminSessionsService {
183
207
  * enumeração de cada model token), feitas só quando o adapter enumera.
184
208
  */
185
209
  async listGrants(accountId) {
186
- const rows = await this.#listModel('Grant');
187
- // Os grants `pgrant_` são o espelho de um Account Token do Poppy (Bearer de MCP) — aparecem e
188
- // são revogados como personal agent, não como app OIDC.
189
- const grants = rows.filter((r) => r.payload.accountId === accountId && !r.id.startsWith('pgrant_'));
210
+ const grants = await this.#accountGrants(accountId);
190
211
  if (grants.length === 0)
191
212
  return [];
192
- const atByGrant = await this.#countByGrant('AccessToken');
193
- const rtByGrant = await this.#countByGrant('RefreshToken');
213
+ const grantIds = new Set(grants.map((g) => g.id));
214
+ const atByGrant = await this.#countByGrant('AccessToken', grantIds);
215
+ const rtByGrant = await this.#countByGrant('RefreshToken', grantIds);
194
216
  return grants.map((g) => ({
195
217
  id: g.id,
196
218
  accountId,
@@ -199,9 +221,9 @@ export class AdminSessionsService {
199
221
  refreshTokens: rtByGrant.get(g.id) ?? 0,
200
222
  }));
201
223
  }
202
- /** Conta artefatos de um model token agrupados por `grantId`. */
203
- async #countByGrant(model) {
204
- const rows = await this.#listModel(model);
224
+ /** Conta os artefatos de um model token dos grants, agrupados por `grantId`. */
225
+ async #countByGrant(model, grantIds) {
226
+ const rows = await this.#listTokensOfGrants(model, grantIds);
205
227
  const map = new Map();
206
228
  for (const r of rows) {
207
229
  const gid = r.payload.grantId;
@@ -231,7 +253,7 @@ export class AdminSessionsService {
231
253
  // Revogamos todos os grants da conta (que pertencem à conta, não à sessão
232
254
  // específica). A sessão corrente gerará novos grants no próximo authorize.
233
255
  // Isso garante que sessões antigas não mantenham tokens vivos.
234
- const grants = await this.listGrants(accountId);
256
+ const grants = await this.#accountGrants(accountId);
235
257
  const grantIds = new Set(grants.map((g) => g.id));
236
258
  let accessTokens = 0;
237
259
  let refreshTokens = 0;
@@ -279,7 +301,7 @@ export class AdminSessionsService {
279
301
  for (const s of sessions) {
280
302
  await sessionAdapter.destroy(s.id);
281
303
  }
282
- const grants = await this.listGrants(accountId);
304
+ const grants = await this.#accountGrants(accountId);
283
305
  const grantIds = new Set(grants.map((g) => g.id));
284
306
  let accessTokens = 0;
285
307
  let refreshTokens = 0;
@@ -314,7 +336,7 @@ export class AdminSessionsService {
314
336
  */
315
337
  async revokeClientGrants(accountId, clientId) {
316
338
  const grantAdapter = this.#adapter('Grant');
317
- const grants = (await this.listGrants(accountId)).filter((g) => g.clientId === clientId);
339
+ const grants = (await this.#accountGrants(accountId)).filter((g) => g.payload.clientId === clientId);
318
340
  const grantIds = new Set(grants.map((g) => g.id));
319
341
  const accessTokens = await this.#destroyTokensOfGrants('AccessToken', grantIds);
320
342
  const refreshTokens = await this.#destroyTokensOfGrants('RefreshToken', grantIds);
@@ -346,12 +368,10 @@ export class AdminSessionsService {
346
368
  */
347
369
  async revokeOrgGrants(orgId, accountId) {
348
370
  const grantAdapter = this.#adapter('Grant');
349
- const rows = await this.#listModel('Grant');
350
- const grants = rows.filter((r) => {
351
- if (normalizeActiveOrg(r.payload.activeOrg)?.orgId !== orgId)
352
- return false;
353
- return accountId === undefined || r.payload.accountId === accountId;
354
- });
371
+ const rows = accountId === undefined
372
+ ? await this.#listModel('Grant')
373
+ : await this.#listModelOfAccount('Grant', accountId);
374
+ const grants = rows.filter((r) => normalizeActiveOrg(r.payload.activeOrg)?.orgId === orgId);
355
375
  if (grants.length === 0) {
356
376
  return { sessions: 0, grants: 0, accessTokens: 0, refreshTokens: 0 };
357
377
  }
@@ -375,17 +395,9 @@ export class AdminSessionsService {
375
395
  /** Destrói (quando enumerável) os artefatos de um model token cujos grantId estão em `grantIds`. */
376
396
  async #destroyTokensOfGrants(model, grantIds) {
377
397
  const adapter = this.#adapter(model);
378
- if (!adapter.list)
379
- return 0;
380
- const rows = await adapter.list();
381
- let count = 0;
382
- for (const r of rows) {
383
- const gid = r.payload.grantId;
384
- if (gid && grantIds.has(gid)) {
385
- await adapter.destroy(r.id);
386
- count++;
387
- }
388
- }
389
- return count;
398
+ const rows = await this.#listTokensOfGrants(model, grantIds);
399
+ for (const r of rows)
400
+ await adapter.destroy(r.id);
401
+ return rows.length;
390
402
  }
391
403
  }
@@ -108,10 +108,9 @@ export declare class RuntimeSettings implements SettingsCapability {
108
108
  private readonly db;
109
109
  private readonly ttlMs;
110
110
  private readonly connectionName;
111
- private cache;
112
- /** null = não foi verificado ainda; false = tabela ausente; true = presente */
113
- private tablePresent;
111
+ private readonly shared;
114
112
  constructor(db: any, opts?: RuntimeSettingsOptions);
113
+ private get cache();
115
114
  /**
116
115
  * Retorna o objeto de conexão Lucid correto.
117
116
  * Quando `connectionName` está definido, usa `db.connection(name)`;
@@ -128,6 +127,9 @@ export declare class RuntimeSettings implements SettingsCapability {
128
127
  * detecta a tabela corretamente independente do schema.
129
128
  *
130
129
  * Fail-safe: qualquer erro → false (tabela considerada ausente, sem lançar).
130
+ * "Presente" vale pela vida do processo; "ausente" é re-verificado após o TTL,
131
+ * para que uma migração rodada com o app no ar (ou um erro transitório) não
132
+ * desligue as settings até o restart.
131
133
  */
132
134
  private hasTable;
133
135
  getSetting(key: string, orgId?: string | null): Promise<unknown | null>;
@@ -57,6 +57,24 @@ import { isSettingLocked, SettingLockedError } from './config_locks.js';
57
57
  export function supportsSettings(obj) {
58
58
  return !!obj && typeof obj.getSetting === 'function';
59
59
  }
60
+ const sharedStates = new WeakMap();
61
+ function sharedStateFor(db, connection) {
62
+ const fresh = () => ({ cache: new Map(), tablePresent: null, tableAbsentUntil: 0 });
63
+ if (!db || (typeof db !== 'object' && typeof db !== 'function'))
64
+ return fresh();
65
+ let byConnection = sharedStates.get(db);
66
+ if (!byConnection) {
67
+ byConnection = new Map();
68
+ sharedStates.set(db, byConnection);
69
+ }
70
+ const name = connection ?? '';
71
+ let state = byConnection.get(name);
72
+ if (!state) {
73
+ state = fresh();
74
+ byConnection.set(name, state);
75
+ }
76
+ return state;
77
+ }
60
78
  /** Chave de cache: combina key + escopo de org (null = global). */
61
79
  function cacheKey(key, orgId) {
62
80
  return orgId ? `${key}\x00${orgId}` : key;
@@ -70,13 +88,15 @@ export class RuntimeSettings {
70
88
  db;
71
89
  ttlMs;
72
90
  connectionName;
73
- cache = new Map();
74
- /** null = não foi verificado ainda; false = tabela ausente; true = presente */
75
- tablePresent = null;
91
+ shared;
76
92
  constructor(db, opts = {}) {
77
93
  this.db = db;
78
94
  this.ttlMs = opts.ttlMs ?? 15_000;
79
95
  this.connectionName = opts.connection;
96
+ this.shared = sharedStateFor(db, opts.connection);
97
+ }
98
+ get cache() {
99
+ return this.shared.cache;
80
100
  }
81
101
  /**
82
102
  * Retorna o objeto de conexão Lucid correto.
@@ -96,17 +116,24 @@ export class RuntimeSettings {
96
116
  * detecta a tabela corretamente independente do schema.
97
117
  *
98
118
  * Fail-safe: qualquer erro → false (tabela considerada ausente, sem lançar).
119
+ * "Presente" vale pela vida do processo; "ausente" é re-verificado após o TTL,
120
+ * para que uma migração rodada com o app no ar (ou um erro transitório) não
121
+ * desligue as settings até o restart.
99
122
  */
100
123
  async hasTable() {
101
- if (this.tablePresent !== null)
102
- return this.tablePresent;
124
+ const shared = this.shared;
125
+ if (shared.tablePresent === true)
126
+ return true;
127
+ if (shared.tablePresent === false && shared.tableAbsentUntil > Date.now())
128
+ return false;
103
129
  try {
104
130
  await this.conn().from('auth_settings').select('key').limit(1);
105
- this.tablePresent = true;
131
+ shared.tablePresent = true;
106
132
  return true;
107
133
  }
108
134
  catch {
109
- this.tablePresent = false;
135
+ shared.tablePresent = false;
136
+ shared.tableAbsentUntil = Date.now() + this.ttlMs;
110
137
  return false;
111
138
  }
112
139
  }