@cortexkit/common-auth 0.1.3 → 0.2.1

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 (47) hide show
  1. package/dist/quota/codec.d.ts +11 -0
  2. package/dist/quota/codec.js +12 -0
  3. package/dist/quota/index.d.ts +8 -0
  4. package/dist/quota/index.js +4 -0
  5. package/dist/quota/map.d.ts +63 -0
  6. package/dist/quota/map.js +105 -0
  7. package/dist/quota/merge.d.ts +48 -0
  8. package/dist/quota/merge.js +162 -0
  9. package/dist/quota/projection.d.ts +57 -0
  10. package/dist/quota/projection.js +127 -0
  11. package/dist/routing/admission.d.ts +93 -0
  12. package/dist/routing/admission.js +140 -0
  13. package/dist/routing/index.d.ts +8 -0
  14. package/dist/routing/index.js +4 -0
  15. package/dist/routing/ordered.d.ts +47 -0
  16. package/dist/routing/ordered.js +58 -0
  17. package/dist/routing/pins.d.ts +22 -0
  18. package/dist/routing/pins.js +34 -0
  19. package/dist/routing/sticky.d.ts +118 -0
  20. package/dist/routing/sticky.js +310 -0
  21. package/dist/store/attribution.d.ts +17 -0
  22. package/dist/store/attribution.js +46 -0
  23. package/dist/store/errors.d.ts +52 -0
  24. package/dist/store/errors.js +38 -0
  25. package/dist/store/hooks.d.ts +12 -0
  26. package/dist/store/hooks.js +34 -0
  27. package/dist/store/identity.d.ts +23 -0
  28. package/dist/store/identity.js +53 -0
  29. package/dist/store/index.d.ts +16 -0
  30. package/dist/store/index.js +5 -0
  31. package/dist/store/mutate.d.ts +124 -0
  32. package/dist/store/mutate.js +339 -0
  33. package/dist/store/pool.d.ts +97 -0
  34. package/dist/store/pool.js +101 -0
  35. package/dist/store/pull.d.ts +36 -0
  36. package/dist/store/pull.js +129 -0
  37. package/dist/store/refresh-lock.d.ts +63 -0
  38. package/dist/store/refresh-lock.js +125 -0
  39. package/dist/store/refresh.d.ts +48 -0
  40. package/dist/store/refresh.js +169 -0
  41. package/dist/store/rows.d.ts +53 -0
  42. package/dist/store/rows.js +250 -0
  43. package/dist/store/runtime.d.ts +28 -0
  44. package/dist/store/runtime.js +45 -0
  45. package/dist/store/schema.d.ts +133 -0
  46. package/dist/store/schema.js +323 -0
  47. package/package.json +13 -1
@@ -0,0 +1,310 @@
1
+ // `sticky-balanced` routing: openai-auth's quota-weighted session placement
2
+ // and pin-break classification, judged over the quota projection rather than
3
+ // openai-auth's fixed primary/secondary snapshot.
4
+ import { budgetExhaustedResetAt, projectQuota, } from '../quota/projection.js';
5
+ import { admit, } from './admission.js';
6
+ import { isPinValid } from './pins.js';
7
+ export const QUOTA_STALENESS_MS = 15 * 60_000;
8
+ export const MIN_RESET_HOURS = 1 / 60;
9
+ export const MIN_WEIGHT = 1e-6;
10
+ /**
11
+ * How many window readings the selection primitives judge, as openai-auth's
12
+ * primary and secondary slots did. The projection's first readings in its
13
+ * order fill the slots; any further window is judged by admission only.
14
+ */
15
+ export const STICKY_WINDOW_SLOTS = 2;
16
+ /** The projection's time, else the caller's cache-entry time. */
17
+ export function snapshotCheckedAt(quota, entryCheckedAt) {
18
+ for (const checkedAt of [quota?.checkedAt, entryCheckedAt]) {
19
+ if (typeof checkedAt === 'number' && Number.isFinite(checkedAt)) {
20
+ return checkedAt;
21
+ }
22
+ }
23
+ return undefined;
24
+ }
25
+ function slotReadings(quota) {
26
+ // Longest known window first, unknown lengths last, as openai-auth sorts.
27
+ // Tombstones and absence records carry no capacity figure, so they occupy
28
+ // no slot.
29
+ return quota.limits
30
+ .filter((limit) => limit.kind === 'reading')
31
+ .sort((left, right) => {
32
+ const leftKnown = left.windowMinutes !== undefined;
33
+ const rightKnown = right.windowMinutes !== undefined;
34
+ if (leftKnown !== rightKnown)
35
+ return leftKnown ? -1 : 1;
36
+ if (leftKnown && rightKnown) {
37
+ return (right.windowMinutes ?? 0) - (left.windowMinutes ?? 0);
38
+ }
39
+ return 0;
40
+ })
41
+ .slice(0, STICKY_WINDOW_SLOTS);
42
+ }
43
+ /** Classifies whether a pinned session should leave its row after a failure. */
44
+ export function decideStickyBreak(input) {
45
+ if (input.status === 401 || input.status === 403) {
46
+ return { action: 'migrate', reason: 'permanent' };
47
+ }
48
+ if (!input.quota)
49
+ return { action: 'retain', reason: 'unknown' };
50
+ const checkedAt = snapshotCheckedAt(input.quota, input.quotaCheckedAt);
51
+ if (checkedAt === undefined ||
52
+ !Number.isFinite(checkedAt) ||
53
+ input.now - checkedAt > QUOTA_STALENESS_MS) {
54
+ return { action: 'retain', reason: 'stale' };
55
+ }
56
+ // After the stale check, so a stale snapshot never judges the account on a
57
+ // reading the killswitch would consider below its floor.
58
+ if (input.killswitchPasses === false) {
59
+ return { action: 'migrate', reason: 'killswitch' };
60
+ }
61
+ for (const limit of slotReadings(input.quota)) {
62
+ const remaining = limit.remainingPercent;
63
+ if (typeof remaining === 'number' &&
64
+ Number.isFinite(remaining) &&
65
+ remaining <= 0) {
66
+ return {
67
+ action: 'migrate',
68
+ reason: 'exhausted',
69
+ window: { scope: limit.scope, label: limit.label },
70
+ ...(typeof limit.resetsAt === 'string'
71
+ ? { resetsAt: limit.resetsAt }
72
+ : {}),
73
+ };
74
+ }
75
+ }
76
+ // A reached credit budget is exhaustion on its own axis, judged by the same
77
+ // signal admission uses so the two never disagree on what "spent" means.
78
+ const budgetReset = budgetExhaustedResetAt(input.quota, input.now);
79
+ if (budgetReset) {
80
+ return {
81
+ action: 'migrate',
82
+ reason: 'exhausted',
83
+ resetsAt: budgetReset.resetsAt,
84
+ };
85
+ }
86
+ if (input.status === undefined ||
87
+ input.status === 0 ||
88
+ !Number.isFinite(input.status) ||
89
+ (input.status >= 500 && input.status <= 599) ||
90
+ input.status === 429) {
91
+ return { action: 'retain', reason: 'transient' };
92
+ }
93
+ return { action: 'retain', reason: 'healthy' };
94
+ }
95
+ export function sustainableWindowWeight(window, reservePercent, now) {
96
+ const spendable = Math.max(0, window.remainingPercent - reservePercent);
97
+ if (spendable <= 0)
98
+ return 0;
99
+ if (!window.resetsAt)
100
+ return spendable;
101
+ const resetMs = Date.parse(window.resetsAt);
102
+ // A lapsed reset cannot yield a spend rate: the divisor would clamp to
103
+ // MIN_RESET_HOURS and inflate the weight about sixty-fold on stale
104
+ // information, so the un-rate-adjusted spendable capacity is used instead.
105
+ if (!Number.isFinite(resetMs) || resetMs <= now)
106
+ return spendable;
107
+ const hours = Math.max((resetMs - now) / 3_600_000, MIN_RESET_HOURS);
108
+ return spendable / hours;
109
+ }
110
+ function compareAccountIds(left, right) {
111
+ if (left < right)
112
+ return -1;
113
+ if (left > right)
114
+ return 1;
115
+ return 0;
116
+ }
117
+ function candidateWeight(candidate, now) {
118
+ if (!candidate.quota)
119
+ return undefined;
120
+ const quotaCheckedAt = snapshotCheckedAt(candidate.quota, candidate.quotaCheckedAt);
121
+ if (quotaCheckedAt === undefined ||
122
+ now - quotaCheckedAt > QUOTA_STALENESS_MS) {
123
+ return undefined;
124
+ }
125
+ // Missing reserve data must leave a window usable rather than silently
126
+ // excluding its account.
127
+ const weights = slotReadings(candidate.quota).map((limit) => sustainableWindowWeight({
128
+ remainingPercent: limit.remainingPercent ?? Number.NaN,
129
+ ...(limit.resetsAt === undefined ? {} : { resetsAt: limit.resetsAt }),
130
+ }, candidate.reservePercent[limit.label] ?? 0, now));
131
+ // The credit budget is a third pressure axis on its own reset clock. It has
132
+ // no configured reserve, and a malformed reading is ignored rather than
133
+ // allowed to zero the account's weight.
134
+ const budget = candidate.quota.budget;
135
+ if (budget &&
136
+ typeof budget.remainingPercent === 'number' &&
137
+ Number.isFinite(budget.remainingPercent)) {
138
+ weights.push(sustainableWindowWeight({
139
+ remainingPercent: budget.remainingPercent,
140
+ ...(budget.resetsAt === undefined
141
+ ? {}
142
+ : { resetsAt: budget.resetsAt }),
143
+ }, 0, now));
144
+ }
145
+ const weight = weights.length > 0 ? Math.min(...weights) : 0;
146
+ return weight > 0 ? { candidate, quotaCheckedAt, weight } : undefined;
147
+ }
148
+ /**
149
+ * Places a session: the lowest projected pressure among candidates with a
150
+ * fresh positive weight, else (`mode-fallback`) the first candidate in
151
+ * configured order, preferring one with an applicable reset credit.
152
+ */
153
+ export function selectStickyCandidate(input) {
154
+ // A candidate killed by the killswitch is excluded from BOTH weighted
155
+ // placement and the fallback branch, which must never become a way to
156
+ // spend on a killed account.
157
+ const eligibleCandidates = input.candidates.filter((candidate) => candidate.killswitchPasses !== false);
158
+ if (input.candidates.length === 0) {
159
+ throw new Error('Cannot select a sticky candidate: input.candidates is empty');
160
+ }
161
+ if (eligibleCandidates.length === 0)
162
+ return undefined;
163
+ const weighted = eligibleCandidates
164
+ .map((candidate) => candidateWeight(candidate, input.now))
165
+ .filter((candidate) => candidate !== undefined);
166
+ if (weighted.length > 0) {
167
+ weighted.sort((left, right) => {
168
+ // MIN_WEIGHT only guards the division; every weight here is positive.
169
+ const leftScore = ((input.pendingBytes.get(left.candidate.accountId) ?? 0) +
170
+ input.requestBytes) /
171
+ Math.max(left.weight, MIN_WEIGHT);
172
+ const rightScore = ((input.pendingBytes.get(right.candidate.accountId) ?? 0) +
173
+ input.requestBytes) /
174
+ Math.max(right.weight, MIN_WEIGHT);
175
+ return (leftScore - rightScore ||
176
+ left.candidate.configuredOrder - right.candidate.configuredOrder ||
177
+ compareAccountIds(left.candidate.accountId, right.candidate.accountId));
178
+ });
179
+ const selected = weighted[0];
180
+ if (selected) {
181
+ return {
182
+ accountId: selected.candidate.accountId,
183
+ quotaCheckedAt: selected.quotaCheckedAt,
184
+ source: 'weighted',
185
+ };
186
+ }
187
+ }
188
+ input.onEmptyWeightedSet?.();
189
+ const fallback = [...eligibleCandidates].sort((left, right) => {
190
+ const leftHasCredits = (left.resetCreditsApplicable ?? 0) > 0 ? 1 : 0;
191
+ const rightHasCredits = (right.resetCreditsApplicable ?? 0) > 0 ? 1 : 0;
192
+ return (rightHasCredits - leftHasCredits ||
193
+ left.configuredOrder - right.configuredOrder ||
194
+ compareAccountIds(left.accountId, right.accountId));
195
+ })[0];
196
+ if (!fallback) {
197
+ throw new Error('Cannot select a sticky candidate: input.candidates is empty');
198
+ }
199
+ return {
200
+ accountId: fallback.accountId,
201
+ quotaCheckedAt: snapshotCheckedAt(fallback.quota, fallback.quotaCheckedAt),
202
+ source: 'mode-fallback',
203
+ };
204
+ }
205
+ /**
206
+ * Routes one request in `sticky-balanced` mode. A valid pin whose row is
207
+ * admitted, not excluded and not killed is dispatched as is. Otherwise
208
+ * selection runs over the non-excluded rows; each selected row admission
209
+ * refused is removed and selection re-runs, so the loop ends either on an
210
+ * admitted row or with no admissible account.
211
+ */
212
+ export function routeSticky(input) {
213
+ const admission = admit(input);
214
+ const admitted = new Map(admission.admitted.map((row) => [row.id, row]));
215
+ const refusals = new Map(admission.refused.map((r) => [r.id, r]));
216
+ const excluded = new Set(admission.excluded.map((row) => row.id));
217
+ const validIds = new Set(input.rows.map((row) => row.id));
218
+ const pinValid = input.pin !== undefined &&
219
+ isPinValid(input.pin, validIds, input.identities?.get(input.pin.accountId));
220
+ if (pinValid && input.pin) {
221
+ const pinned = admitted.get(input.pin.accountId);
222
+ if (pinned && input.killswitch?.get(pinned.id) !== false) {
223
+ return {
224
+ outcome: 'dispatch',
225
+ accountId: pinned.id,
226
+ source: 'pin',
227
+ ...(pinned.projection?.checkedAt === undefined
228
+ ? {}
229
+ : { quotaCheckedAt: pinned.projection.checkedAt }),
230
+ pin: { action: 'retain' },
231
+ refusedSelections: [],
232
+ admission,
233
+ };
234
+ }
235
+ }
236
+ const scope = input.scope;
237
+ let candidates = input.rows
238
+ .map((row, configuredOrder) => ({ row, configuredOrder }))
239
+ .filter(({ row }) => !excluded.has(row.id))
240
+ .map(({ row, configuredOrder }) => {
241
+ const killswitchPasses = input.killswitch?.get(row.id);
242
+ const credits = input.resetCreditsApplicable?.get(row.id);
243
+ return {
244
+ accountId: row.id,
245
+ quota: row.kind === 'api-key'
246
+ ? undefined
247
+ : (admitted.get(row.id)?.projection ??
248
+ projectQuota(row.quota, scope)),
249
+ reservePercent: input.reservePercent ?? {},
250
+ configuredOrder,
251
+ ...(credits === undefined ? {} : { resetCreditsApplicable: credits }),
252
+ ...(killswitchPasses === undefined ? {} : { killswitchPasses }),
253
+ };
254
+ });
255
+ const refusedSelections = [];
256
+ const unplaced = pinValid
257
+ ? { action: 'retain' }
258
+ : input.pin
259
+ ? { action: 'clear' }
260
+ : { action: 'none' };
261
+ while (candidates.length > 0) {
262
+ const selection = selectStickyCandidate({
263
+ candidates,
264
+ pendingBytes: input.pendingBytes ?? new Map(),
265
+ requestBytes: input.requestBytes,
266
+ now: input.now,
267
+ ...(input.onEmptyWeightedSet
268
+ ? { onEmptyWeightedSet: input.onEmptyWeightedSet }
269
+ : {}),
270
+ });
271
+ if (!selection)
272
+ break;
273
+ const refusal = refusals.get(selection.accountId);
274
+ if (refusal) {
275
+ refusedSelections.push(refusal);
276
+ candidates = candidates.filter((candidate) => candidate.accountId !== selection.accountId);
277
+ continue;
278
+ }
279
+ const identity = input.identities?.get(selection.accountId);
280
+ return {
281
+ outcome: 'dispatch',
282
+ accountId: selection.accountId,
283
+ source: selection.source,
284
+ ...(selection.quotaCheckedAt === undefined
285
+ ? {}
286
+ : { quotaCheckedAt: selection.quotaCheckedAt }),
287
+ pin: pinValid
288
+ ? { action: 'retain' }
289
+ : {
290
+ action: 'assign',
291
+ pin: {
292
+ accountId: selection.accountId,
293
+ inputBytes: input.requestBytes,
294
+ ...(identity === undefined ? {} : { wireIdentity: identity }),
295
+ ...(selection.quotaCheckedAt === undefined
296
+ ? {}
297
+ : { quotaCheckedAt: selection.quotaCheckedAt }),
298
+ },
299
+ },
300
+ refusedSelections,
301
+ admission,
302
+ };
303
+ }
304
+ return {
305
+ outcome: 'no-admissible-account',
306
+ pin: unplaced,
307
+ refusedSelections,
308
+ admission,
309
+ };
310
+ }
@@ -0,0 +1,17 @@
1
+ import { type StoreRuntime } from './runtime.js';
2
+ /**
3
+ * What a pull or refresh captured about its row (named by id alongside) when
4
+ * it was issued. A result applies only while the row with that id still has
5
+ * this credential epoch and this recorded identity; a replaced credential bumps the epoch, so work issued
6
+ * for the old one is discarded.
7
+ */
8
+ export interface Attribution {
9
+ credentialEpoch: number;
10
+ identity?: string;
11
+ }
12
+ /**
13
+ * Merges a quota observation into a row's stored map under the store locks,
14
+ * after attribution passes. Needs no row lock, so it is permitted from
15
+ * inside hooks. Clears needs-first-reading on success.
16
+ */
17
+ export declare function recordQuota(rt: StoreRuntime, id: string, attribution: Attribution, observation: unknown): Promise<void>;
@@ -0,0 +1,46 @@
1
+ import { PoolOperationError } from './errors.js';
2
+ import { toFailure, withTransaction } from './mutate.js';
3
+ import { LockStack } from './refresh-lock.js';
4
+ import { refusal, unknownRow } from './runtime.js';
5
+ /**
6
+ * Merges a quota observation into a row's stored map under the store locks,
7
+ * after attribution passes. Needs no row lock, so it is permitted from
8
+ * inside hooks. Clears needs-first-reading on success.
9
+ */
10
+ export async function recordQuota(rt, id, attribution, observation) {
11
+ const { ctx } = rt;
12
+ const locks = new LockStack(ctx.lockDefaults, ctx.lockEnv);
13
+ const progress = { writes: 0 };
14
+ try {
15
+ await withTransaction(ctx, locks, progress, { operation: 'pull', rowId: id }, async (tx) => {
16
+ const row = tx.row(id);
17
+ if (!row)
18
+ throw unknownRow('pull', id);
19
+ if (row.invalid)
20
+ throw refusal('pull', id, 'invalid-row', `row ${id} is invalid`);
21
+ const entry = tx.entry(id);
22
+ if (!entry ||
23
+ entry.credentialEpoch !== attribution.credentialEpoch ||
24
+ row.identity !== attribution.identity)
25
+ throw new PoolOperationError({
26
+ operation: 'pull',
27
+ rowId: id,
28
+ phase: 'pull',
29
+ retryable: true,
30
+ kind: 'attribution',
31
+ message: `quota for ${id} was issued for a credential the row no longer holds`,
32
+ });
33
+ const merged = ctx.codec.merge(entry.quota, observation);
34
+ if (!ctx.codec.validate(merged))
35
+ throw refusal('pull', id, 'invalid-quota', 'the quota codec rejected the merged map');
36
+ tx.setEntry(id, { ...entry, quota: merged, needsFirstReading: false });
37
+ await tx.commitConfig();
38
+ });
39
+ }
40
+ catch (error) {
41
+ throw toFailure(error, 'pull', id, progress);
42
+ }
43
+ finally {
44
+ await locks.releaseAll();
45
+ }
46
+ }
@@ -0,0 +1,52 @@
1
+ import type { StoredCredential } from './schema.js';
2
+ /** Every library operation that can fail, as named in the failure value. */
3
+ export type PoolOperation = 'initialize' | 'add' | 'replace' | 'rotate' | 'disable' | 'recordIdentity' | 'refresh' | 'pull';
4
+ /**
5
+ * How far an operation got before it failed.
6
+ *
7
+ * `before-first-write`: nothing was written; both files are as they were.
8
+ * `after-first-write`: the operation's first file write landed and a later one
9
+ * did not; what that first write left is on disk and is never rolled back
10
+ * (add: a row with no credential; replace: the bumped epoch beside the prior
11
+ * credential; rotate: the rotated credential beside the old per-row entry). `pull`: a quota pull, or the recording of its result, failed.
12
+ */
13
+ export type PoolFailurePhase = 'before-first-write' | 'after-first-write' | 'pull';
14
+ /**
15
+ * Why an operation failed. `lock-contention` and `lock-ownership` are the two
16
+ * lock outcomes (a wait that ran out, and a lease found lost); the rest are
17
+ * refusals and failures of the operation itself.
18
+ */
19
+ export type PoolFailureKind = 'lock-contention' | 'lock-ownership' | 'pending-migration' | 'load-error' | 'unknown-row' | 'invalid-row' | 'invalid-input' | 'id-exists' | 'id-removed' | 'type-mismatch' | 'no-credential' | 'row-disabled' | 'row-key-changed' | 'refresh-stamp-ahead' | 'attribution' | 'provider' | 'pull' | 'invalid-quota' | 'after-persist-hook' | 'unexpected';
20
+ /**
21
+ * The single failure value of every store operation. `committed` is present
22
+ * only when the operation had already written a credential to the state file
23
+ * before it failed (a partial rotation or refresh): it is the credential now
24
+ * on disk, so a caller never has to re-read the files to learn it.
25
+ */
26
+ export declare class PoolOperationError extends Error {
27
+ readonly operation: PoolOperation;
28
+ readonly rowId: string | undefined;
29
+ readonly phase: PoolFailurePhase;
30
+ readonly retryable: boolean;
31
+ readonly kind: PoolFailureKind;
32
+ readonly committed: StoredCredential | undefined;
33
+ constructor(details: {
34
+ operation: PoolOperation;
35
+ rowId?: string;
36
+ phase: PoolFailurePhase;
37
+ retryable: boolean;
38
+ kind: PoolFailureKind;
39
+ committed?: StoredCredential;
40
+ message?: string;
41
+ cause?: unknown;
42
+ });
43
+ }
44
+ /**
45
+ * Thrown when a row operation or a refresh is called from inside a hook of a
46
+ * lock-holding operation (or from any continuation created inside one). It is
47
+ * thrown before any lock is taken or waited for.
48
+ */
49
+ export declare class PoolReentryError extends Error {
50
+ readonly operation: PoolOperation;
51
+ constructor(operation: PoolOperation);
52
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The single failure value of every store operation. `committed` is present
3
+ * only when the operation had already written a credential to the state file
4
+ * before it failed (a partial rotation or refresh): it is the credential now
5
+ * on disk, so a caller never has to re-read the files to learn it.
6
+ */
7
+ export class PoolOperationError extends Error {
8
+ operation;
9
+ rowId;
10
+ phase;
11
+ retryable;
12
+ kind;
13
+ committed;
14
+ constructor(details) {
15
+ super(details.message ??
16
+ `${details.operation} failed (${details.kind}, ${details.phase})`, details.cause === undefined ? undefined : { cause: details.cause });
17
+ this.name = 'PoolOperationError';
18
+ this.operation = details.operation;
19
+ this.rowId = details.rowId;
20
+ this.phase = details.phase;
21
+ this.retryable = details.retryable;
22
+ this.kind = details.kind;
23
+ this.committed = details.committed;
24
+ }
25
+ }
26
+ /**
27
+ * Thrown when a row operation or a refresh is called from inside a hook of a
28
+ * lock-holding operation (or from any continuation created inside one). It is
29
+ * thrown before any lock is taken or waited for.
30
+ */
31
+ export class PoolReentryError extends Error {
32
+ operation;
33
+ constructor(operation) {
34
+ super(`${operation} was called from inside a store hook; hand it to the caller's continuation instead`);
35
+ this.name = 'PoolReentryError';
36
+ this.operation = operation;
37
+ }
38
+ }
@@ -0,0 +1,12 @@
1
+ import { type PoolOperation } from './errors.js';
2
+ /** Refuses a row operation or refresh called from inside a hook. */
3
+ export declare function assertNotInsideHook(operation: PoolOperation): void;
4
+ export declare function runInsideHook<T>(operation: PoolOperation, fn: () => Promise<T> | T): Promise<T>;
5
+ export interface PoolLogger {
6
+ warn(message: string, data?: unknown): void;
7
+ }
8
+ /**
9
+ * Runs a failure hook. A hook that throws never replaces the failure it was
10
+ * handed: its exception is logged and discarded.
11
+ */
12
+ export declare function callFailureHook<E>(operation: PoolOperation, hook: ((rowId: string, error: E) => void | Promise<void>) | undefined, rowId: string, error: E, logger: PoolLogger | undefined): Promise<void>;
@@ -0,0 +1,34 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import { PoolReentryError } from './errors.js';
3
+ /**
4
+ * Set while a hook of a lock-holding operation runs. Async context is
5
+ * inherited by every timer, promise and continuation created inside the hook,
6
+ * so work a hook schedules is inside the guarded region too.
7
+ */
8
+ const insideHook = new AsyncLocalStorage();
9
+ /** Refuses a row operation or refresh called from inside a hook. */
10
+ export function assertNotInsideHook(operation) {
11
+ if (insideHook.getStore() !== undefined)
12
+ throw new PoolReentryError(operation);
13
+ }
14
+ export function runInsideHook(operation, fn) {
15
+ return insideHook.run(operation, async () => await fn());
16
+ }
17
+ /**
18
+ * Runs a failure hook. A hook that throws never replaces the failure it was
19
+ * handed: its exception is logged and discarded.
20
+ */
21
+ export async function callFailureHook(operation, hook, rowId, error, logger) {
22
+ if (!hook)
23
+ return;
24
+ try {
25
+ await runInsideHook(operation, () => hook(rowId, error));
26
+ }
27
+ catch (hookError) {
28
+ logger?.warn('store failure hook threw; the original failure stands', {
29
+ operation,
30
+ rowId,
31
+ error: hookError instanceof Error ? hookError.message : String(hookError),
32
+ });
33
+ }
34
+ }
@@ -0,0 +1,23 @@
1
+ import type { Transaction } from './mutate.js';
2
+ import type { PoolRow } from './schema.js';
3
+ /** The reason recorded on a row disabled because an earlier row is the same account. */
4
+ export declare const DUPLICATE_IDENTITY_REASON = "duplicate-identity";
5
+ /**
6
+ * Enabled OAuth rows holding a credential whose wire identity is not yet
7
+ * known. API-key rows and disabled rows never count.
8
+ */
9
+ export declare function countUnknownIdentityRows(rows: readonly PoolRow[]): number;
10
+ /**
11
+ * Marks a row disabled with a reason: `enabled: false` in the roster row,
12
+ * which older readers honour, and the reason in the per-row entry. A row
13
+ * without an entry gets one at epoch 1. Nothing is ever deleted.
14
+ */
15
+ export declare function disableIn(tx: Transaction, id: string, reason: string): void;
16
+ /**
17
+ * Two enabled OAuth rows with one wire identity are the same account: the
18
+ * earlier row in roster order stays enabled and every later one is disabled
19
+ * with a reason. Returns the ids it disabled.
20
+ */
21
+ export declare function disableIdentityDuplicates(tx: Transaction, identity: string): string[];
22
+ /** Records a row's wire identity in its roster row, then applies dedupe. */
23
+ export declare function recordIdentityIn(tx: Transaction, id: string, identity: string): string[];
@@ -0,0 +1,53 @@
1
+ /** The reason recorded on a row disabled because an earlier row is the same account. */
2
+ export const DUPLICATE_IDENTITY_REASON = 'duplicate-identity';
3
+ /**
4
+ * Enabled OAuth rows holding a credential whose wire identity is not yet
5
+ * known. API-key rows and disabled rows never count.
6
+ */
7
+ export function countUnknownIdentityRows(rows) {
8
+ return rows.filter((row) => row.invalid === undefined &&
9
+ row.type === 'oauth' &&
10
+ row.enabled &&
11
+ row.credential !== undefined &&
12
+ row.identity === undefined).length;
13
+ }
14
+ /**
15
+ * Marks a row disabled with a reason: `enabled: false` in the roster row,
16
+ * which older readers honour, and the reason in the per-row entry. A row
17
+ * without an entry gets one at epoch 1. Nothing is ever deleted.
18
+ */
19
+ export function disableIn(tx, id, reason) {
20
+ const raw = tx.rosterRow(id);
21
+ if (!raw)
22
+ return;
23
+ raw.enabled = false;
24
+ const entry = tx.entry(id) ?? { credentialEpoch: 1, needsFirstReading: true };
25
+ tx.setEntry(id, { ...entry, disabledReason: reason });
26
+ }
27
+ /**
28
+ * Two enabled OAuth rows with one wire identity are the same account: the
29
+ * earlier row in roster order stays enabled and every later one is disabled
30
+ * with a reason. Returns the ids it disabled.
31
+ */
32
+ export function disableIdentityDuplicates(tx, identity) {
33
+ const holders = tx
34
+ .rows()
35
+ .filter((row) => row.invalid === undefined &&
36
+ row.type === 'oauth' &&
37
+ row.enabled &&
38
+ row.identity === identity);
39
+ const disabled = [];
40
+ for (const row of holders.slice(1)) {
41
+ disableIn(tx, row.id, DUPLICATE_IDENTITY_REASON);
42
+ disabled.push(row.id);
43
+ }
44
+ return disabled;
45
+ }
46
+ /** Records a row's wire identity in its roster row, then applies dedupe. */
47
+ export function recordIdentityIn(tx, id, identity) {
48
+ const raw = tx.rosterRow(id);
49
+ if (!raw)
50
+ return [];
51
+ raw.accountId = identity;
52
+ return disableIdentityDuplicates(tx, identity);
53
+ }
@@ -0,0 +1,16 @@
1
+ export type { Attribution } from './attribution.js';
2
+ export type { PoolFailureKind, PoolFailurePhase, PoolOperation, } from './errors.js';
3
+ export { PoolOperationError, PoolReentryError } from './errors.js';
4
+ export type { PoolLogger } from './hooks.js';
5
+ export { countUnknownIdentityRows, DUPLICATE_IDENTITY_REASON, } from './identity.js';
6
+ export type { HoldPoint, InitializeOutcome, WriteStep } from './mutate.js';
7
+ export type { OpenPoolStoreOptions, PoolLoad, PoolStore, } from './pool.js';
8
+ export { openPoolStore } from './pool.js';
9
+ export type { PullHook, PullRequest } from './pull.js';
10
+ export type { ProviderRefresh, ProviderRefreshResult, RefreshOptions, RefreshOutcome, } from './refresh.js';
11
+ export type { LockEvent, PoolLockOptions, PoolLockSpec, } from './refresh-lock.js';
12
+ export { POOL_LOCK_DEFAULTS } from './refresh-lock.js';
13
+ export type { AddInput, AddResult, FailureHook, RowOperationOptions, } from './rows.js';
14
+ export type { PullReason } from './runtime.js';
15
+ export type { ApiKeyCredential, OAuthCredential, PoolCredential, PoolRow, QuotaCodec, StoredCredential, } from './schema.js';
16
+ export { fingerprintOf, LEGACY_STORE_VERSION, POOL_KEY, POOL_ROWS_KEY, POOL_SCHEMA_VERSION, REFRESH_STAMP_TOLERANCE_MS, rowLockKey, } from './schema.js';
@@ -0,0 +1,5 @@
1
+ export { PoolOperationError, PoolReentryError } from './errors.js';
2
+ export { countUnknownIdentityRows, DUPLICATE_IDENTITY_REASON, } from './identity.js';
3
+ export { openPoolStore } from './pool.js';
4
+ export { POOL_LOCK_DEFAULTS } from './refresh-lock.js';
5
+ export { fingerprintOf, LEGACY_STORE_VERSION, POOL_KEY, POOL_ROWS_KEY, POOL_SCHEMA_VERSION, REFRESH_STAMP_TOLERANCE_MS, rowLockKey, } from './schema.js';