@sunerpy/opencode-kiro-auth 0.15.0 → 0.15.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.
@@ -1,5 +1,6 @@
1
1
  import type { AccountRepository } from '../../infrastructure/database/account-repository.js';
2
2
  import type { AccountManager } from '../../plugin/accounts.js';
3
+ import { refreshAccessToken } from '../../plugin/token.js';
3
4
  import type { KiroAuthDetails, ManagedAccount } from '../../plugin/types.js';
4
5
  type ToastFunction = (message: string, variant: 'info' | 'warning' | 'success' | 'error') => void;
5
6
  interface TokenRefresherConfig {
@@ -7,6 +8,11 @@ interface TokenRefresherConfig {
7
8
  auto_sync_kiro_cli: boolean;
8
9
  account_selection_strategy: 'sticky' | 'round-robin' | 'lowest-usage';
9
10
  }
11
+ interface TokenRefresherDependencies {
12
+ refreshAccessToken: typeof refreshAccessToken;
13
+ sleep: (delayMs: number) => Promise<void>;
14
+ random: () => number;
15
+ }
10
16
  /** Outcome of a forced refresh; `dead` distinguishes refresh-token-dead
11
17
  * (needs re-login) from a transient failure (network/5xx). */
12
18
  export interface ForceRefreshResult {
@@ -27,8 +33,12 @@ export declare class TokenRefresher {
27
33
  private syncFromKiroCli;
28
34
  private repository;
29
35
  private readonly inFlight;
36
+ private readonly pendingPersistence;
30
37
  private readonly lastDeadToastAt;
31
- constructor(config: TokenRefresherConfig, accountManager: AccountManager, syncFromKiroCli: () => Promise<void>, repository: AccountRepository);
38
+ private readonly refreshAccessToken;
39
+ private readonly sleep;
40
+ private readonly random;
41
+ constructor(config: TokenRefresherConfig, accountManager: AccountManager, syncFromKiroCli: () => Promise<void>, repository: AccountRepository, dependencies?: Partial<TokenRefresherDependencies>);
32
42
  refreshIfNeeded(account: ManagedAccount, auth: KiroAuthDetails, showToast: ToastFunction): Promise<{
33
43
  account: ManagedAccount;
34
44
  shouldContinue: boolean;
@@ -36,7 +46,9 @@ export declare class TokenRefresher {
36
46
  forceRefresh(account: ManagedAccount, showToast: ToastFunction): Promise<ForceRefreshResult>;
37
47
  private startOrJoinRefresh;
38
48
  private runLockedRefresh;
49
+ private persistRefreshedAccount;
39
50
  private readLatestAuth;
51
+ private syncPersistedAccountReference;
40
52
  private handleRefreshError;
41
53
  }
42
54
  export {};
@@ -1,10 +1,24 @@
1
1
  import { accessTokenExpired } from '../../kiro/auth.js';
2
- import { KiroTokenRefreshError } from '../../plugin/errors.js';
2
+ import { KiroTokenRefreshError, TokenPersistenceError } from '../../plugin/errors.js';
3
3
  import { isRefreshTokenDead, toDeadReason } from '../../plugin/health.js';
4
4
  import * as logger from '../../plugin/logger.js';
5
5
  import { withRefreshLock } from '../../plugin/storage/locked-operations.js';
6
6
  import { refreshAccessToken } from '../../plugin/token.js';
7
7
  const DEAD_TOAST_DEBOUNCE_MS = 60000;
8
+ const TOKEN_PERSISTENCE_MAX_ATTEMPTS = 3;
9
+ const TOKEN_PERSISTENCE_RETRY_DELAY_MS = 250;
10
+ function isTransientPersistenceError(error) {
11
+ const code = typeof error === 'object' && error !== null && 'code' in error
12
+ ? String(error.code).toUpperCase()
13
+ : '';
14
+ const message = error instanceof Error ? error.message : String(error);
15
+ return (code === 'SQLITE_BUSY' ||
16
+ code === 'SQLITE_LOCKED' ||
17
+ /SQLITE_(?:BUSY|LOCKED)|database is locked|lock file is already being held/i.test(message));
18
+ }
19
+ function defaultSleep(ms) {
20
+ return new Promise((resolve) => setTimeout(resolve, ms));
21
+ }
8
22
  /**
9
23
  * Decide whether a refresh failure means the refresh token / OIDC client is
10
24
  * dead (permanent, needs re-login) or is merely transient (network/5xx).
@@ -13,6 +27,9 @@ const DEAD_TOAST_DEBOUNCE_MS = 60000;
13
27
  * the stored credentials are unusable, so the account needs a re-login.
14
28
  */
15
29
  export function isRefreshErrorDead(error) {
30
+ if (error instanceof TokenPersistenceError) {
31
+ return false;
32
+ }
16
33
  if (error instanceof KiroTokenRefreshError) {
17
34
  if (error.code === 'MISSING_CREDENTIALS' || error.code === 'INVALID_RESPONSE') {
18
35
  return true;
@@ -39,12 +56,19 @@ export class TokenRefresher {
39
56
  syncFromKiroCli;
40
57
  repository;
41
58
  inFlight = new Map();
59
+ pendingPersistence = new Map();
42
60
  lastDeadToastAt = new Map();
43
- constructor(config, accountManager, syncFromKiroCli, repository) {
61
+ refreshAccessToken;
62
+ sleep;
63
+ random;
64
+ constructor(config, accountManager, syncFromKiroCli, repository, dependencies = {}) {
44
65
  this.config = config;
45
66
  this.accountManager = accountManager;
46
67
  this.syncFromKiroCli = syncFromKiroCli;
47
68
  this.repository = repository;
69
+ this.refreshAccessToken = dependencies.refreshAccessToken ?? refreshAccessToken;
70
+ this.sleep = dependencies.sleep ?? defaultSleep;
71
+ this.random = dependencies.random ?? Math.random;
48
72
  }
49
73
  async refreshIfNeeded(account, auth, showToast) {
50
74
  if (!accessTokenExpired(auth, this.config.token_expiry_buffer_ms)) {
@@ -64,6 +88,10 @@ export class TokenRefresher {
64
88
  return { ok: true, dead: false };
65
89
  }
66
90
  catch (e) {
91
+ if (e instanceof TokenPersistenceError) {
92
+ logger.error('Forced token refresh persistence failed', { email: account.email });
93
+ return { ok: false, dead: false };
94
+ }
67
95
  const dead = isRefreshErrorDead(e);
68
96
  logger.error('Forced token refresh failed', {
69
97
  email: account.email,
@@ -94,16 +122,50 @@ export class TokenRefresher {
94
122
  }
95
123
  async runLockedRefresh(account, getAuthFallback) {
96
124
  await withRefreshLock(account.id, async () => {
97
- const { latestAuth } = await this.readLatestAuth(account);
98
- if (latestAuth && !accessTokenExpired(latestAuth, this.config.token_expiry_buffer_ms)) {
99
- this.accountManager.updateFromAuth(account, latestAuth);
125
+ const { latestAccount, latestAuth } = await this.readLatestAuth(account);
126
+ if (latestAccount &&
127
+ latestAuth &&
128
+ !accessTokenExpired(latestAuth, this.config.token_expiry_buffer_ms)) {
129
+ this.pendingPersistence.delete(account.id);
130
+ this.accountManager.publishAuthCandidate(latestAccount, false);
100
131
  return;
101
132
  }
102
- const newAuth = await refreshAccessToken(latestAuth ?? getAuthFallback());
103
- this.accountManager.updateFromAuth(account, newAuth);
104
- await this.repository.batchSave(this.accountManager.getAccounts());
133
+ const pendingCandidate = this.pendingPersistence.get(account.id);
134
+ if (pendingCandidate) {
135
+ await this.persistRefreshedAccount(pendingCandidate);
136
+ this.pendingPersistence.delete(account.id);
137
+ this.accountManager.publishAuthCandidate(pendingCandidate);
138
+ return;
139
+ }
140
+ const newAuth = await this.refreshAccessToken(latestAuth ?? getAuthFallback());
141
+ const candidate = this.accountManager.createAuthCandidate(latestAccount ?? account, newAuth);
142
+ this.pendingPersistence.set(account.id, candidate);
143
+ await this.persistRefreshedAccount(candidate);
144
+ this.pendingPersistence.delete(account.id);
145
+ this.accountManager.publishAuthCandidate(candidate);
105
146
  });
106
147
  }
148
+ async persistRefreshedAccount(candidate) {
149
+ for (let attempt = 1; attempt <= TOKEN_PERSISTENCE_MAX_ATTEMPTS; attempt++) {
150
+ try {
151
+ await this.repository.save(candidate);
152
+ return;
153
+ }
154
+ catch (error) {
155
+ const retryable = isTransientPersistenceError(error);
156
+ if (!retryable || attempt === TOKEN_PERSISTENCE_MAX_ATTEMPTS) {
157
+ throw new TokenPersistenceError();
158
+ }
159
+ logger.warn('Token persistence failed; retrying', {
160
+ email: candidate.email,
161
+ attempt,
162
+ maxAttempts: TOKEN_PERSISTENCE_MAX_ATTEMPTS
163
+ });
164
+ const jitter = Math.floor(TOKEN_PERSISTENCE_RETRY_DELAY_MS * 0.25 * this.random());
165
+ await this.sleep(TOKEN_PERSISTENCE_RETRY_DELAY_MS * attempt + jitter);
166
+ }
167
+ }
168
+ }
107
169
  async readLatestAuth(account) {
108
170
  this.repository.invalidateCache();
109
171
  const accounts = await this.repository.findAll();
@@ -111,42 +173,41 @@ export class TokenRefresher {
111
173
  if (!latestAccount) {
112
174
  return { latestAccount: null, latestAuth: null };
113
175
  }
176
+ this.syncPersistedAccountReference(account, latestAccount);
177
+ return {
178
+ latestAccount,
179
+ latestAuth: this.accountManager.toAuthDetails(account)
180
+ };
181
+ }
182
+ syncPersistedAccountReference(account, latestAccount) {
114
183
  account.email = latestAccount.email;
115
184
  account.authMethod = latestAccount.authMethod;
116
185
  account.region = latestAccount.region;
117
- if (latestAccount.oidcRegion !== undefined) {
118
- account.oidcRegion = latestAccount.oidcRegion;
119
- }
120
- else {
186
+ if (latestAccount.oidcRegion === undefined)
121
187
  delete account.oidcRegion;
122
- }
123
- if (latestAccount.clientId !== undefined) {
124
- account.clientId = latestAccount.clientId;
125
- }
126
- else {
188
+ else
189
+ account.oidcRegion = latestAccount.oidcRegion;
190
+ if (latestAccount.clientId === undefined)
127
191
  delete account.clientId;
128
- }
129
- if (latestAccount.clientSecret !== undefined) {
130
- account.clientSecret = latestAccount.clientSecret;
131
- }
132
- else {
192
+ else
193
+ account.clientId = latestAccount.clientId;
194
+ if (latestAccount.clientSecret === undefined)
133
195
  delete account.clientSecret;
134
- }
135
- if (latestAccount.profileArn !== undefined) {
136
- account.profileArn = latestAccount.profileArn;
137
- }
138
- else {
196
+ else
197
+ account.clientSecret = latestAccount.clientSecret;
198
+ if (latestAccount.profileArn === undefined)
139
199
  delete account.profileArn;
140
- }
200
+ else
201
+ account.profileArn = latestAccount.profileArn;
141
202
  account.refreshToken = latestAccount.refreshToken;
142
203
  account.accessToken = latestAccount.accessToken;
143
204
  account.expiresAt = latestAccount.expiresAt;
144
- return {
145
- latestAccount,
146
- latestAuth: this.accountManager.toAuthDetails(account)
147
- };
148
205
  }
149
206
  async handleRefreshError(error, account, showToast) {
207
+ if (error instanceof TokenPersistenceError) {
208
+ logger.error('Token refresh persistence failed', { email: account.email });
209
+ return { account, shouldContinue: true };
210
+ }
150
211
  const message = error instanceof Error ? error.message : String(error);
151
212
  logger.error('Token refresh failed', {
152
213
  email: account.email,
@@ -118,6 +118,11 @@ export class ErrorHandler {
118
118
  if (result.dead) {
119
119
  return this.markDeadAndSwitchOrFail(account, errorReason, response.status, context, nextForced, showToast);
120
120
  }
121
+ // Record this account as already force-refreshed even on a transient
122
+ // failure: the at-most-once-per-request force-refresh invariant bounds
123
+ // the retry loop. A candidate that was refreshed but not yet persisted
124
+ // is retried by TokenRefresher's pendingPersistence path, which does
125
+ // not re-call AWS, so dropping nextForced here is unnecessary.
121
126
  return this.transientForbidden(errorReason, response.status, { ...context, forcedRefreshAccountIds: nextForced }, showToast, signal);
122
127
  }
123
128
  return this.markDeadAndSwitchOrFail(account, errorReason, response.status, context, forced, showToast);
@@ -14,6 +14,7 @@ import { RetryStrategy } from './retry-strategy.js';
14
14
  import { SdkEventStreamIterationError, UpstreamUnexpectedError } from './stream-error.js';
15
15
  const KIRO_API_PATTERN = /^(https?:\/\/)?q\.[a-z0-9-]+\.amazonaws\.com/;
16
16
  const REAUTH_FAILURE_COOLDOWN_MS = 60000;
17
+ const MAX_STREAM_ATTEMPTS = 3;
17
18
  function describeError(error, depth = 0) {
18
19
  if (!(error instanceof Error))
19
20
  return String(error);
@@ -182,6 +183,18 @@ export class RequestHandler {
182
183
  continue;
183
184
  }
184
185
  const sdkPrep = this.prepareSdkRequest(init?.body, model, auth, think, budget, showToast);
186
+ const streamAttempt = streamFailureCount + 1;
187
+ const streamLogDetails = (details = {}) => ({
188
+ conversationId: sdkPrep.conversationId,
189
+ model,
190
+ effectiveModel: sdkPrep.effectiveModel,
191
+ region: sdkPrep.region,
192
+ account: acc.email,
193
+ accountId: acc.id,
194
+ streamAttempt,
195
+ maxStreamAttempts: MAX_STREAM_ATTEMPTS,
196
+ ...details
197
+ });
185
198
  if (this.config.enable_log_effort_debug) {
186
199
  try {
187
200
  logger.log('[effort-debug] request effort resolution', {
@@ -231,6 +244,7 @@ export class RequestHandler {
231
244
  if (this.config.sdk_response_timeout_enabled) {
232
245
  const messageContext = sdkPrep.conversationState.currentMessage?.userInputMessage?.userInputMessageContext;
233
246
  beginUpstreamWait('SDK response', this.config.sdk_response_timeout_ms, {
247
+ conversationId: sdkPrep.conversationId,
234
248
  model,
235
249
  effectiveModel: sdkPrep.effectiveModel,
236
250
  effort: sdkPrep.effort,
@@ -261,6 +275,7 @@ export class RequestHandler {
261
275
  if (!this.config.stream_event_timeout_enabled)
262
276
  return;
263
277
  beginUpstreamWait('stream event', this.config.request_timeout_ms, {
278
+ conversationId: sdkPrep.conversationId,
264
279
  model,
265
280
  effectiveModel: sdkPrep.effectiveModel,
266
281
  region: sdkPrep.region,
@@ -268,20 +283,32 @@ export class RequestHandler {
268
283
  });
269
284
  },
270
285
  onUpstreamWaitEnd: endUpstreamWait,
271
- onIterationError: (error, afterCompletionMetadata) => logger.warn('Kiro SDK event stream iteration failed', {
272
- model,
273
- effectiveModel: sdkPrep.effectiveModel,
274
- region: sdkPrep.region,
275
- platform: process.platform,
276
- afterCompletionMetadata,
277
- recovered: afterCompletionMetadata,
278
- error: describeError(error)
279
- }),
286
+ onIterationError: (error, afterCompletionMetadata) => {
287
+ if (!afterCompletionMetadata)
288
+ return;
289
+ logger.log('Kiro SDK event stream closed after completion metadata', streamLogDetails({
290
+ outcome: 'ignored_after_completion_metadata',
291
+ platform: process.platform,
292
+ afterCompletionMetadata,
293
+ error: describeError(error)
294
+ }));
295
+ },
280
296
  onComplete: completeRequest,
281
297
  onTerminal: cleanupRequest,
282
298
  onCancel: (reason) => requestController.abort(reason),
283
- mapError: (error) => new UpstreamUnexpectedError(error, true)
299
+ mapError: (error) => {
300
+ logger.error('Kiro SDK event stream iteration failed', streamLogDetails({
301
+ outcome: 'terminated_after_output',
302
+ platform: process.platform,
303
+ emittedOutput: true,
304
+ error: describeError(error)
305
+ }));
306
+ return new UpstreamUnexpectedError(error, true);
307
+ }
284
308
  });
309
+ if (streamFailureCount > 0) {
310
+ logger.log('Kiro SDK event stream retry recovered', streamLogDetails({ outcome: 'recovered', attempts: streamAttempt }));
311
+ }
285
312
  if (sdkPrep.streaming) {
286
313
  responseOwnsLifecycle = true;
287
314
  }
@@ -296,9 +323,17 @@ export class RequestHandler {
296
323
  if (e instanceof SdkEventStreamIterationError) {
297
324
  streamFailureCount++;
298
325
  const streamError = new UpstreamUnexpectedError(e, false);
299
- if (streamFailureCount >= 3)
326
+ if (streamFailureCount >= MAX_STREAM_ATTEMPTS) {
327
+ logger.error('Kiro SDK event stream iteration failed', streamLogDetails({
328
+ outcome: 'exhausted',
329
+ platform: process.platform,
330
+ attempts: streamFailureCount,
331
+ error: describeError(e)
332
+ }));
300
333
  return streamError.toResponse();
301
- await this.sleep(this.getStreamRetryDelay(streamFailureCount), signal);
334
+ }
335
+ const delayMs = this.getStreamRetryDelay(streamFailureCount);
336
+ await this.sleep(delayMs, signal);
302
337
  if (streamFailureCount === 1) {
303
338
  forcedStreamAccount = acc;
304
339
  }
@@ -306,6 +341,14 @@ export class RequestHandler {
306
341
  forcedStreamAccount =
307
342
  (await this.accountSelector.selectAlternativeAccount(new Set([acc.id]))) ?? acc;
308
343
  }
344
+ logger.warn('Kiro SDK event stream iteration failed', streamLogDetails({
345
+ outcome: 'retrying',
346
+ platform: process.platform,
347
+ nextAttempt: streamFailureCount + 1,
348
+ delayMs,
349
+ nextAccount: forcedStreamAccount.email,
350
+ error: describeError(e)
351
+ }));
309
352
  continue;
310
353
  }
311
354
  if (sendResolved)
@@ -50,6 +50,9 @@ export declare class AccountManager {
50
50
  addAccount(a: ManagedAccount): void;
51
51
  removeAccount(a: ManagedAccount): void;
52
52
  updateFromAuth(a: ManagedAccount, auth: KiroAuthDetails): void;
53
+ createAuthCandidate(a: ManagedAccount, auth: KiroAuthDetails): ManagedAccount;
54
+ publishAuthCandidate(candidate: ManagedAccount, syncKiroCli?: boolean): void;
55
+ private writeAuthCandidateToKiroCli;
53
56
  markRateLimited(a: ManagedAccount, ms: number): void;
54
57
  markUnhealthy(a: ManagedAccount, reason: string, recovery?: number): void;
55
58
  saveToDisk(): Promise<void>;
@@ -287,34 +287,56 @@ export class AccountManager {
287
287
  this.cursor--;
288
288
  }
289
289
  updateFromAuth(a, auth) {
290
- const acc = this.accounts.find((x) => x.id === a.id);
291
- if (acc) {
292
- acc.accessToken = auth.access;
293
- acc.expiresAt = auth.expires;
294
- acc.lastUsed = Date.now();
295
- if (auth.email)
296
- acc.email = auth.email;
297
- const p = decodeRefreshToken(auth.refresh);
298
- acc.refreshToken = p.refreshToken;
299
- if (p.profileArn)
300
- acc.profileArn = p.profileArn;
301
- if (p.clientId)
302
- acc.clientId = p.clientId;
303
- acc.failCount = 0;
304
- acc.isHealthy = true;
305
- delete acc.unhealthyReason;
306
- delete acc.recoveryTime;
307
- kiroDb.upsertAccount(acc).catch((e) => logger.warn('DB write failed', {
308
- method: 'updateFromAuth',
309
- email: acc.email,
310
- error: e instanceof Error ? e.message : String(e)
311
- }));
312
- writeToKiroCli(acc).catch((e) => logger.warn('CLI write failed', {
313
- method: 'updateFromAuth',
314
- email: acc.email,
315
- error: e instanceof Error ? e.message : String(e)
316
- }));
317
- }
290
+ const account = this.accounts.find((item) => item.id === a.id);
291
+ if (!account)
292
+ return;
293
+ const candidate = this.createAuthCandidate(account, auth);
294
+ this.publishAuthCandidate(candidate, false);
295
+ kiroDb.upsertAccount(candidate).catch((e) => logger.warn('DB write failed', {
296
+ method: 'updateFromAuth',
297
+ email: candidate.email,
298
+ error: e instanceof Error ? e.message : String(e)
299
+ }));
300
+ this.writeAuthCandidateToKiroCli(candidate);
301
+ }
302
+ createAuthCandidate(a, auth) {
303
+ const candidate = { ...a };
304
+ candidate.accessToken = auth.access;
305
+ candidate.expiresAt = auth.expires;
306
+ candidate.lastUsed = Date.now();
307
+ if (auth.email)
308
+ candidate.email = auth.email;
309
+ const p = decodeRefreshToken(auth.refresh);
310
+ candidate.refreshToken = p.refreshToken;
311
+ if (p.profileArn)
312
+ candidate.profileArn = p.profileArn;
313
+ if (p.clientId)
314
+ candidate.clientId = p.clientId;
315
+ candidate.failCount = 0;
316
+ candidate.isHealthy = true;
317
+ delete candidate.unhealthyReason;
318
+ delete candidate.recoveryTime;
319
+ return candidate;
320
+ }
321
+ publishAuthCandidate(candidate, syncKiroCli = true) {
322
+ const account = this.accounts.find((item) => item.id === candidate.id);
323
+ if (!account)
324
+ return;
325
+ Object.assign(account, candidate);
326
+ if (candidate.unhealthyReason === undefined)
327
+ delete account.unhealthyReason;
328
+ if (candidate.recoveryTime === undefined)
329
+ delete account.recoveryTime;
330
+ if (!syncKiroCli)
331
+ return;
332
+ this.writeAuthCandidateToKiroCli(account);
333
+ }
334
+ writeAuthCandidateToKiroCli(account) {
335
+ writeToKiroCli(account).catch((e) => logger.warn('CLI write failed', {
336
+ method: 'updateFromAuth',
337
+ email: account.email,
338
+ error: e instanceof Error ? e.message : String(e)
339
+ }));
318
340
  }
319
341
  markRateLimited(a, ms) {
320
342
  const acc = this.accounts.find((x) => x.id === a.id);
@@ -3,6 +3,9 @@ export declare class KiroTokenRefreshError extends Error {
3
3
  originalError?: Error;
4
4
  constructor(message: string, code?: string, originalError?: Error);
5
5
  }
6
+ export declare class TokenPersistenceError extends Error {
7
+ constructor();
8
+ }
6
9
  export declare class KiroQuotaExhaustedError extends Error {
7
10
  recoveryTime?: number;
8
11
  constructor(message: string, recoveryTime?: number);
@@ -8,6 +8,12 @@ export class KiroTokenRefreshError extends Error {
8
8
  this.originalError = originalError;
9
9
  }
10
10
  }
11
+ export class TokenPersistenceError extends Error {
12
+ constructor() {
13
+ super('Failed to persist refreshed credentials');
14
+ this.name = 'TokenPersistenceError';
15
+ }
16
+ }
11
17
  export class KiroQuotaExhaustedError extends Error {
12
18
  recoveryTime;
13
19
  constructor(message, recoveryTime) {
@@ -2,7 +2,6 @@ import type { ManagedAccount } from '../types.js';
2
2
  export { getKeepAliveLockPath, getRefreshLockPath } from '../paths.js';
3
3
  type LockRelease = () => Promise<void>;
4
4
  export declare function withDatabaseLockSync<T>(dbPath: string, fn: () => T): T;
5
- export declare function withDatabaseLock<T>(dbPath: string, fn: () => Promise<T>): Promise<T>;
6
5
  export declare function withRefreshLock<T>(accountId: string, fn: () => Promise<T>): Promise<T>;
7
6
  export declare function tryAcquireKeepAliveLock(): Promise<LockRelease | null>;
8
7
  export declare function withKeepAliveLock<T>(fn: () => Promise<T>): Promise<T | null>;
@@ -5,24 +5,14 @@ import lockfile from 'proper-lockfile';
5
5
  import { isPermanentError } from '../health.js';
6
6
  import { getKeepAliveLockPath, getRefreshLockPath } from '../paths.js';
7
7
  export { getKeepAliveLockPath, getRefreshLockPath } from '../paths.js';
8
- const DATABASE_LOCK_OPTIONS = {
9
- stale: 10000,
10
- retries: 0,
11
- realpath: false
12
- };
13
- const DATABASE_LOCK_DEADLINE_MS = 10000;
14
- const DATABASE_LOCK_MIN_BACKOFF_MS = 25;
15
- const DATABASE_LOCK_MAX_BACKOFF_MS = 250;
16
8
  const REFRESH_LOCK_OPTIONS = {
17
9
  stale: 15000,
18
- retries: {
19
- retries: 10,
20
- minTimeout: 100,
21
- maxTimeout: 1000,
22
- factor: 2
23
- },
10
+ retries: 0,
24
11
  realpath: false
25
12
  };
13
+ const REFRESH_LOCK_DEADLINE_MS = 15000;
14
+ const REFRESH_LOCK_MIN_BACKOFF_MS = 25;
15
+ const REFRESH_LOCK_MAX_BACKOFF_MS = 250;
26
16
  const KEEP_ALIVE_LOCK_OPTIONS = {
27
17
  stale: 120000,
28
18
  retries: 0,
@@ -33,29 +23,35 @@ const SYNC_LOCK_DEADLINE_MS = 10000;
33
23
  function blockingBackoff(ms) {
34
24
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
35
25
  }
36
- function isLockContention(e) {
37
- return typeof e === 'object' && e !== null && 'code' in e && e.code === 'ELOCKED';
26
+ function isRetryableLockAcquisitionError(e) {
27
+ if (typeof e !== 'object' || e === null || !('code' in e))
28
+ return false;
29
+ // proper-lockfile may surface ENOENT from mtimePrecision.probe() when another
30
+ // process removes the newly-created lock directory during stale takeover.
31
+ // Keep this scoped to the bounded lock-acquisition loops below so unrelated
32
+ // filesystem ENOENT errors still propagate immediately.
33
+ return e.code === 'ELOCKED' || e.code === 'ENOENT';
38
34
  }
39
- function asyncBackoff(attempt, remainingMs) {
40
- const ceiling = Math.min(DATABASE_LOCK_MIN_BACKOFF_MS * 2 ** Math.min(attempt, 4), DATABASE_LOCK_MAX_BACKOFF_MS, remainingMs);
35
+ function asyncBackoff(attempt, remainingMs, minBackoffMs, maxBackoffMs) {
36
+ const ceiling = Math.min(minBackoffMs * 2 ** Math.min(attempt, 4), maxBackoffMs, remainingMs);
41
37
  const floor = Math.max(1, Math.floor(ceiling / 2));
42
38
  const delay = floor + Math.floor(Math.random() * (ceiling - floor + 1));
43
39
  return new Promise((resolve) => setTimeout(resolve, delay));
44
40
  }
45
- async function acquireDatabaseLock(dbPath) {
41
+ async function acquireRefreshLock(lockPath) {
46
42
  // A deadline avoids fixed retry-count starvation; jitter keeps contenders
47
43
  // from repeatedly attempting the atomic mkdir in lockstep.
48
- const deadline = Date.now() + DATABASE_LOCK_DEADLINE_MS;
44
+ const deadline = Date.now() + REFRESH_LOCK_DEADLINE_MS;
49
45
  let attempt = 0;
50
46
  for (;;) {
51
47
  try {
52
- return await lockfile.lock(dbPath, DATABASE_LOCK_OPTIONS);
48
+ return await lockfile.lock(lockPath, REFRESH_LOCK_OPTIONS);
53
49
  }
54
50
  catch (e) {
55
51
  const remainingMs = deadline - Date.now();
56
- if (!isLockContention(e) || remainingMs <= 0)
52
+ if (!isRetryableLockAcquisitionError(e) || remainingMs <= 0)
57
53
  throw e;
58
- await asyncBackoff(attempt++, remainingMs);
54
+ await asyncBackoff(attempt++, remainingMs, REFRESH_LOCK_MIN_BACKOFF_MS, REFRESH_LOCK_MAX_BACKOFF_MS);
59
55
  }
60
56
  }
61
57
  }
@@ -75,7 +71,7 @@ export function withDatabaseLockSync(dbPath, fn) {
75
71
  break;
76
72
  }
77
73
  catch (e) {
78
- if (!isLockContention(e) || Date.now() >= deadline)
74
+ if (!isRetryableLockAcquisitionError(e) || Date.now() >= deadline)
79
75
  throw e;
80
76
  blockingBackoff(Math.min(100 * 2 ** attempt++, 500));
81
77
  }
@@ -92,27 +88,6 @@ export function withDatabaseLockSync(dbPath, fn) {
92
88
  }
93
89
  }
94
90
  }
95
- export async function withDatabaseLock(dbPath, fn) {
96
- if (!existsSync(dbPath)) {
97
- await fs.mkdir(dirname(dbPath), { recursive: true });
98
- await fs.writeFile(dbPath, '');
99
- }
100
- let release = null;
101
- try {
102
- release = await acquireDatabaseLock(dbPath);
103
- return await fn();
104
- }
105
- finally {
106
- if (release) {
107
- try {
108
- await release();
109
- }
110
- catch (e) {
111
- console.warn('Failed to release lock:', e);
112
- }
113
- }
114
- }
115
- }
116
91
  export async function withRefreshLock(accountId, fn) {
117
92
  const lockPath = getRefreshLockPath(accountId);
118
93
  if (!existsSync(lockPath)) {
@@ -121,7 +96,7 @@ export async function withRefreshLock(accountId, fn) {
121
96
  }
122
97
  let release = null;
123
98
  try {
124
- release = await lockfile.lock(lockPath, REFRESH_LOCK_OPTIONS);
99
+ release = await acquireRefreshLock(lockPath);
125
100
  return await fn();
126
101
  }
127
102
  finally {
@@ -1,4 +1,56 @@
1
+ const REFRESH_TOKEN_DEDUP_MARKER = 'refresh_token_dedup_migration_version';
2
+ const REFRESH_TOKEN_DEDUP_VERSION = 1;
3
+ const MIGRATION_LOCK_DEADLINE_MS = 30_000;
4
+ const MIGRATION_LOCK_MIN_BACKOFF_MS = 25;
5
+ const MIGRATION_LOCK_MAX_BACKOFF_MS = 500;
6
+ class MigrationLockTimeoutError extends Error {
7
+ name = 'MigrationLockTimeoutError';
8
+ code = 'KIRO_DB_MIGRATION_LOCK_TIMEOUT';
9
+ constructor(cause) {
10
+ super(`Timed out after ${MIGRATION_LOCK_DEADLINE_MS}ms waiting for the migration write lock`, {
11
+ cause
12
+ });
13
+ }
14
+ }
15
+ function isSqliteBusy(error) {
16
+ if (typeof error !== 'object' || error === null || !('code' in error))
17
+ return false;
18
+ return typeof error.code === 'string' && error.code.startsWith('SQLITE_BUSY');
19
+ }
20
+ function blockingBackoff(ms) {
21
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
22
+ }
23
+ function beginImmediateWithRetry(db) {
24
+ const deadline = Date.now() + MIGRATION_LOCK_DEADLINE_MS;
25
+ let backoffMs = MIGRATION_LOCK_MIN_BACKOFF_MS;
26
+ for (;;) {
27
+ try {
28
+ db.exec('BEGIN IMMEDIATE');
29
+ return;
30
+ }
31
+ catch (error) {
32
+ if (!isSqliteBusy(error))
33
+ throw error;
34
+ const remainingMs = deadline - Date.now();
35
+ if (remainingMs <= 0)
36
+ throw new MigrationLockTimeoutError(error);
37
+ const jitterMs = Math.floor(Math.random() * Math.max(1, backoffMs / 4));
38
+ blockingBackoff(Math.min(backoffMs + jitterMs, remainingMs));
39
+ backoffMs = Math.min(backoffMs * 2, MIGRATION_LOCK_MAX_BACKOFF_MS);
40
+ }
41
+ }
42
+ }
43
+ function hasRefreshTokenDedupMarker(db) {
44
+ const marker = db
45
+ .prepare('SELECT value FROM plugin_meta WHERE key = ?')
46
+ .get(REFRESH_TOKEN_DEDUP_MARKER);
47
+ if (typeof marker !== 'object' || marker === null)
48
+ return false;
49
+ const value = 'value' in marker ? marker.value : undefined;
50
+ return typeof value === 'number' && value >= REFRESH_TOKEN_DEDUP_VERSION;
51
+ }
1
52
  export function runMigrations(db) {
53
+ migratePluginMetaTable(db);
2
54
  migrateToUniqueRefreshToken(db);
3
55
  migrateRealEmailColumn(db);
4
56
  migrateUsageTable(db);
@@ -7,19 +59,18 @@ export function runMigrations(db) {
7
59
  migrateDropRefreshTokenUniqueIndex(db);
8
60
  migrateRemovedAccountsTable(db);
9
61
  migrateOverageColumn(db);
10
- migratePluginMetaTable(db);
11
62
  }
12
63
  function migrateToUniqueRefreshToken(db) {
13
- const indexProbe = db.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_refresh_token_unique'");
14
- if (indexProbe.get())
64
+ if (hasRefreshTokenDedupMarker(db))
15
65
  return;
66
+ const indexProbe = db.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_refresh_token_unique'");
16
67
  // BEGIN IMMEDIATE (not deferred BEGIN): a deferred read-then-write txn raises unrecoverable
17
68
  // SQLITE_BUSY_SNAPSHOT (busy_timeout cannot retry it) if another connection writes after the
18
69
  // read snapshot. Taking the write lock at BEGIN makes concurrent processes serialize via
19
70
  // busy_timeout instead of racing into a snapshot conflict.
20
- db.exec('BEGIN IMMEDIATE');
71
+ beginImmediateWithRetry(db);
21
72
  try {
22
- if (indexProbe.get()) {
73
+ if (hasRefreshTokenDedupMarker(db)) {
23
74
  db.exec('COMMIT');
24
75
  return;
25
76
  }
@@ -55,7 +106,11 @@ function migrateToUniqueRefreshToken(db) {
55
106
  }
56
107
  }
57
108
  }
58
- db.exec('CREATE UNIQUE INDEX idx_refresh_token_unique ON accounts(refresh_token)');
109
+ if (!indexProbe.get()) {
110
+ db.exec('CREATE UNIQUE INDEX idx_refresh_token_unique ON accounts(refresh_token)');
111
+ }
112
+ db.prepare(`INSERT INTO plugin_meta(key, value) VALUES(?, ?)
113
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`).run(REFRESH_TOKEN_DEDUP_MARKER, REFRESH_TOKEN_DEDUP_VERSION);
59
114
  db.exec('COMMIT');
60
115
  }
61
116
  catch (e) {
@@ -134,14 +189,23 @@ function migrateOidcRegionColumn(db) {
134
189
  db.exec('ALTER TABLE accounts ADD COLUMN oidc_region TEXT');
135
190
  }
136
191
  // Backfill: historically `region` was used for both service + OIDC.
137
- db.exec("UPDATE accounts SET oidc_region = region WHERE oidc_region IS NULL OR oidc_region = ''");
192
+ const needsBackfill = db
193
+ .prepare("SELECT 1 FROM accounts WHERE oidc_region IS NULL OR oidc_region = '' LIMIT 1")
194
+ .get();
195
+ if (needsBackfill) {
196
+ db.exec("UPDATE accounts SET oidc_region = region WHERE oidc_region IS NULL OR oidc_region = ''");
197
+ }
138
198
  }
139
199
  function migrateDropRefreshTokenUniqueIndex(db) {
140
200
  // Drop the UNIQUE index on refresh_token — it was only needed for ON CONFLICT(refresh_token)
141
201
  // upsert mechanics. Now that we use ON CONFLICT(id), this index is unnecessary and actively
142
202
  // harmful: duplicate rows (same account, different legacy vs hash id) share the same
143
203
  // refresh_token, causing UNIQUE constraint violations on every upsert.
144
- db.exec('DROP INDEX IF EXISTS idx_refresh_token_unique');
204
+ const hasUniqueIndex = db
205
+ .prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_refresh_token_unique'")
206
+ .get();
207
+ if (hasUniqueIndex)
208
+ db.exec('DROP INDEX IF EXISTS idx_refresh_token_unique');
145
209
  // Clean up duplicate rows: same email + same refresh_token but different ids.
146
210
  // Keep the deterministic hash id (64-char hex), delete legacy kiro-cli-sync-* rows.
147
211
  const duplicates = db
@@ -175,5 +239,18 @@ function migrateOverageColumn(db) {
175
239
  }
176
240
  }
177
241
  function migratePluginMetaTable(db) {
178
- db.exec('CREATE TABLE IF NOT EXISTS plugin_meta (key TEXT PRIMARY KEY, value INTEGER NOT NULL)');
242
+ const tableProbe = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='plugin_meta'");
243
+ if (tableProbe.get())
244
+ return;
245
+ beginImmediateWithRetry(db);
246
+ try {
247
+ if (!tableProbe.get()) {
248
+ db.exec('CREATE TABLE plugin_meta (key TEXT PRIMARY KEY, value INTEGER NOT NULL)');
249
+ }
250
+ db.exec('COMMIT');
251
+ }
252
+ catch (error) {
253
+ db.exec('ROLLBACK');
254
+ throw error;
255
+ }
179
256
  }
@@ -4,6 +4,7 @@ export declare class KiroDatabase {
4
4
  private db;
5
5
  private path;
6
6
  constructor(path?: string);
7
+ private withImmediateTransaction;
7
8
  private init;
8
9
  getAccounts(): any[];
9
10
  private upsertAccountInternal;
@@ -2,8 +2,32 @@ import Database from 'libsql';
2
2
  import { existsSync, mkdirSync } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
4
  import { join } from 'node:path';
5
- import { deduplicateAccounts, mergeAccounts, withDatabaseLock, withDatabaseLockSync } from './locked-operations.js';
5
+ import { deduplicateAccounts, mergeAccounts, withDatabaseLockSync } from './locked-operations.js';
6
6
  import { runMigrations } from './migrations.js';
7
+ const DATABASE_BUSY_TIMEOUT_MS = 5_000;
8
+ const WRITE_LOCK_DEADLINE_MS = 30_000;
9
+ const WRITE_LOCK_MIN_BACKOFF_MS = 25;
10
+ const WRITE_LOCK_MAX_BACKOFF_MS = 500;
11
+ class DatabaseWriteLockTimeoutError extends Error {
12
+ name = 'DatabaseWriteLockTimeoutError';
13
+ code = 'KIRO_DB_WRITE_LOCK_TIMEOUT';
14
+ constructor(cause) {
15
+ super(`Timed out after ${WRITE_LOCK_DEADLINE_MS}ms waiting for the database write lock`, {
16
+ cause
17
+ });
18
+ }
19
+ }
20
+ function isSqliteBusy(error) {
21
+ if (typeof error !== 'object' || error === null || !('code' in error))
22
+ return false;
23
+ return typeof error.code === 'string' && error.code.startsWith('SQLITE_BUSY');
24
+ }
25
+ function asyncBackoff(attempt, remainingMs) {
26
+ const ceiling = Math.min(WRITE_LOCK_MIN_BACKOFF_MS * 2 ** Math.min(attempt, 5), WRITE_LOCK_MAX_BACKOFF_MS, remainingMs);
27
+ const floor = Math.max(1, Math.floor(ceiling / 2));
28
+ const delay = floor + Math.floor(Math.random() * (ceiling - floor + 1));
29
+ return new Promise((resolve) => setTimeout(resolve, delay));
30
+ }
7
31
  function getBaseDir() {
8
32
  const p = process.platform;
9
33
  if (p === 'win32')
@@ -20,9 +44,55 @@ export class KiroDatabase {
20
44
  if (!existsSync(dir))
21
45
  mkdirSync(dir, { recursive: true });
22
46
  this.db = new Database(path);
23
- this.db.pragma('busy_timeout = 5000');
47
+ this.db.pragma(`busy_timeout = ${DATABASE_BUSY_TIMEOUT_MS}`);
24
48
  withDatabaseLockSync(this.path, () => this.init());
25
49
  }
50
+ async withImmediateTransaction(fn) {
51
+ const deadline = Date.now() + WRITE_LOCK_DEADLINE_MS;
52
+ let attempt = 0;
53
+ for (;;) {
54
+ const busyTimeoutResult = this.db.pragma('busy_timeout', { simple: true });
55
+ const busyTimeout = typeof busyTimeoutResult === 'number'
56
+ ? busyTimeoutResult
57
+ : typeof busyTimeoutResult === 'object' && busyTimeoutResult !== null
58
+ ? Reflect.get(busyTimeoutResult, 'timeout')
59
+ : undefined;
60
+ if (typeof busyTimeout !== 'number') {
61
+ throw new TypeError('Expected numeric busy_timeout from libsql');
62
+ }
63
+ let begun = false;
64
+ let beginError;
65
+ try {
66
+ this.db.pragma('busy_timeout = 0');
67
+ this.db.exec('BEGIN IMMEDIATE');
68
+ begun = true;
69
+ }
70
+ catch (error) {
71
+ beginError = error;
72
+ }
73
+ finally {
74
+ this.db.pragma(`busy_timeout = ${busyTimeout}`);
75
+ }
76
+ if (!begun) {
77
+ if (!isSqliteBusy(beginError))
78
+ throw beginError;
79
+ const remainingMs = deadline - Date.now();
80
+ if (remainingMs <= 0)
81
+ throw new DatabaseWriteLockTimeoutError(beginError);
82
+ await asyncBackoff(attempt++, remainingMs);
83
+ continue;
84
+ }
85
+ try {
86
+ const result = fn();
87
+ this.db.exec('COMMIT');
88
+ return result;
89
+ }
90
+ catch (error) {
91
+ this.db.exec('ROLLBACK');
92
+ throw error;
93
+ }
94
+ }
95
+ }
26
96
  init() {
27
97
  this.db.pragma('journal_mode = WAL');
28
98
  this.db.exec(`
@@ -79,96 +149,64 @@ export class KiroDatabase {
79
149
  this.db.prepare('DELETE FROM accounts WHERE id IN (SELECT id FROM removed_accounts)').run();
80
150
  }
81
151
  async upsertAccount(acc) {
82
- await withDatabaseLock(this.path, async () => {
152
+ await this.withImmediateTransaction(() => {
83
153
  const existing = this.getAccounts().map(this.rowToAccount);
84
154
  const merged = mergeAccounts(existing, [acc]);
85
155
  const deduplicated = deduplicateAccounts(merged);
86
156
  const writable = deduplicated.filter((a) => !this.isRemovedSync(a.id));
87
- this.db.exec('BEGIN TRANSACTION');
88
- try {
89
- this.purgeRemovedAccountsSync();
90
- for (const account of writable) {
91
- this.upsertAccountInternal(account);
92
- }
93
- this.db.exec('COMMIT');
94
- }
95
- catch (e) {
96
- this.db.exec('ROLLBACK');
97
- throw e;
157
+ this.purgeRemovedAccountsSync();
158
+ for (const account of writable) {
159
+ this.upsertAccountInternal(account);
98
160
  }
99
161
  });
100
162
  }
101
163
  async batchUpsertAccounts(accounts) {
102
- await withDatabaseLock(this.path, async () => {
164
+ await this.withImmediateTransaction(() => {
103
165
  const existing = this.getAccounts().map(this.rowToAccount);
104
166
  const merged = mergeAccounts(existing, accounts);
105
167
  const deduplicated = deduplicateAccounts(merged);
106
168
  const writable = deduplicated.filter((a) => !this.isRemovedSync(a.id));
107
- this.db.exec('BEGIN TRANSACTION');
108
- try {
109
- this.purgeRemovedAccountsSync();
110
- for (const account of writable) {
111
- this.upsertAccountInternal(account);
112
- }
113
- this.db.exec('COMMIT');
114
- }
115
- catch (e) {
116
- this.db.exec('ROLLBACK');
117
- throw e;
169
+ this.purgeRemovedAccountsSync();
170
+ for (const account of writable) {
171
+ this.upsertAccountInternal(account);
118
172
  }
119
173
  });
120
174
  }
121
175
  async deleteAccount(id) {
122
- await withDatabaseLock(this.path, async () => {
176
+ await this.withImmediateTransaction(() => {
123
177
  this.db.prepare('DELETE FROM accounts WHERE id = ?').run(id);
124
178
  });
125
179
  }
126
180
  async removeAccountWithTombstone(id) {
127
- await withDatabaseLock(this.path, async () => {
128
- this.db.exec('BEGIN TRANSACTION');
129
- try {
130
- this.db.prepare('DELETE FROM accounts WHERE id = ?').run(id);
131
- this.db
132
- .prepare('INSERT OR REPLACE INTO removed_accounts (id, removed_at) VALUES (?, ?)')
133
- .run(id, Date.now());
134
- this.db.exec('COMMIT');
135
- }
136
- catch (e) {
137
- this.db.exec('ROLLBACK');
138
- throw e;
139
- }
181
+ await this.withImmediateTransaction(() => {
182
+ this.db.prepare('DELETE FROM accounts WHERE id = ?').run(id);
183
+ this.db
184
+ .prepare('INSERT OR REPLACE INTO removed_accounts (id, removed_at) VALUES (?, ?)')
185
+ .run(id, Date.now());
140
186
  });
141
187
  }
142
188
  async cleanupSupersededIdentities(keepId, email, authMethod, profileArn) {
143
- const supersededIds = [];
144
- await withDatabaseLock(this.path, async () => {
145
- this.db.exec('BEGIN TRANSACTION');
146
- try {
147
- const rows = this.db
148
- .prepare('SELECT id FROM accounts WHERE email = ? AND auth_method = ? AND profile_arn IS ? AND id != ?')
149
- .all(email, authMethod, profileArn ?? null, keepId);
150
- for (const row of rows) {
151
- if (typeof row === 'object' && row !== null && 'id' in row && typeof row.id === 'string')
152
- supersededIds.push(row.id);
153
- }
154
- const deleteStmt = this.db.prepare('DELETE FROM accounts WHERE id = ?');
155
- const tombstoneStmt = this.db.prepare('INSERT OR REPLACE INTO removed_accounts (id, removed_at) VALUES (?, ?)');
156
- const removedAt = Date.now();
157
- for (const id of supersededIds) {
158
- deleteStmt.run(id);
159
- tombstoneStmt.run(id, removedAt);
160
- }
161
- this.db.exec('COMMIT');
189
+ return this.withImmediateTransaction(() => {
190
+ const supersededIds = [];
191
+ const rows = this.db
192
+ .prepare('SELECT id FROM accounts WHERE email = ? AND auth_method = ? AND profile_arn IS ? AND id != ?')
193
+ .all(email, authMethod, profileArn ?? null, keepId);
194
+ for (const row of rows) {
195
+ if (typeof row === 'object' && row !== null && 'id' in row && typeof row.id === 'string')
196
+ supersededIds.push(row.id);
162
197
  }
163
- catch (e) {
164
- this.db.exec('ROLLBACK');
165
- throw e;
198
+ const deleteStmt = this.db.prepare('DELETE FROM accounts WHERE id = ?');
199
+ const tombstoneStmt = this.db.prepare('INSERT OR REPLACE INTO removed_accounts (id, removed_at) VALUES (?, ?)');
200
+ const removedAt = Date.now();
201
+ for (const id of supersededIds) {
202
+ deleteStmt.run(id);
203
+ tombstoneStmt.run(id, removedAt);
166
204
  }
205
+ return supersededIds;
167
206
  });
168
- return supersededIds;
169
207
  }
170
208
  async addRemovedAccount(id) {
171
- await withDatabaseLock(this.path, async () => {
209
+ await this.withImmediateTransaction(() => {
172
210
  this.db
173
211
  .prepare('INSERT OR REPLACE INTO removed_accounts (id, removed_at) VALUES (?, ?)')
174
212
  .run(id, Date.now());
@@ -178,7 +216,7 @@ export class KiroDatabase {
178
216
  return !!this.db.prepare('SELECT id FROM removed_accounts WHERE id = ?').get(id);
179
217
  }
180
218
  async clearRemovedAccount(id) {
181
- await withDatabaseLock(this.path, async () => {
219
+ await this.withImmediateTransaction(() => {
182
220
  this.db.prepare('DELETE FROM removed_accounts WHERE id = ?').run(id);
183
221
  });
184
222
  }
@@ -186,7 +224,7 @@ export class KiroDatabase {
186
224
  return this.db.prepare('SELECT id FROM removed_accounts').all().map((r) => r.id);
187
225
  }
188
226
  async nextAssignmentIndex() {
189
- return withDatabaseLock(this.path, async () => {
227
+ return this.withImmediateTransaction(() => {
190
228
  const row = this.db
191
229
  .prepare("INSERT INTO plugin_meta(key,value) VALUES('assignment_cursor',0) ON CONFLICT(key) DO UPDATE SET value=value+1 RETURNING value")
192
230
  .get();
@@ -196,11 +234,9 @@ export class KiroDatabase {
196
234
  async markAccountsUnhealthy(ids, reason) {
197
235
  if (ids.length === 0)
198
236
  return;
199
- await withDatabaseLock(this.path, async () => {
237
+ await this.withImmediateTransaction(() => {
200
238
  const now = Date.now();
201
- this.db.exec('BEGIN TRANSACTION');
202
- try {
203
- const stmt = this.db.prepare(`
239
+ const stmt = this.db.prepare(`
204
240
  UPDATE accounts
205
241
  SET is_healthy = 0,
206
242
  unhealthy_reason = ?,
@@ -210,14 +246,8 @@ export class KiroDatabase {
210
246
  last_sync = ?
211
247
  WHERE id = ?
212
248
  `);
213
- for (const id of ids) {
214
- stmt.run(reason, now, id);
215
- }
216
- this.db.exec('COMMIT');
217
- }
218
- catch (e) {
219
- this.db.exec('ROLLBACK');
220
- throw e;
249
+ for (const id of ids) {
250
+ stmt.run(reason, now, id);
221
251
  }
222
252
  });
223
253
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sunerpy/opencode-kiro-auth",
3
- "version": "0.15.0",
3
+ "version": "0.15.2",
4
4
  "description": "OpenCode plugin for AWS Kiro (CodeWhisperer) providing access to Claude models",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",