@cortexkit/common-auth 0.1.3 → 0.2.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/dist/quota/codec.d.ts +11 -0
- package/dist/quota/codec.js +12 -0
- package/dist/quota/index.d.ts +8 -0
- package/dist/quota/index.js +4 -0
- package/dist/quota/map.d.ts +63 -0
- package/dist/quota/map.js +105 -0
- package/dist/quota/merge.d.ts +48 -0
- package/dist/quota/merge.js +162 -0
- package/dist/quota/projection.d.ts +57 -0
- package/dist/quota/projection.js +127 -0
- package/dist/routing/admission.d.ts +93 -0
- package/dist/routing/admission.js +140 -0
- package/dist/routing/index.d.ts +8 -0
- package/dist/routing/index.js +4 -0
- package/dist/routing/ordered.d.ts +47 -0
- package/dist/routing/ordered.js +58 -0
- package/dist/routing/pins.d.ts +22 -0
- package/dist/routing/pins.js +34 -0
- package/dist/routing/sticky.d.ts +118 -0
- package/dist/routing/sticky.js +310 -0
- package/dist/store/attribution.d.ts +17 -0
- package/dist/store/attribution.js +46 -0
- package/dist/store/errors.d.ts +52 -0
- package/dist/store/errors.js +38 -0
- package/dist/store/hooks.d.ts +12 -0
- package/dist/store/hooks.js +34 -0
- package/dist/store/identity.d.ts +23 -0
- package/dist/store/identity.js +53 -0
- package/dist/store/index.d.ts +16 -0
- package/dist/store/index.js +5 -0
- package/dist/store/mutate.d.ts +111 -0
- package/dist/store/mutate.js +294 -0
- package/dist/store/pool.d.ts +86 -0
- package/dist/store/pool.js +98 -0
- package/dist/store/pull.d.ts +36 -0
- package/dist/store/pull.js +129 -0
- package/dist/store/refresh-lock.d.ts +63 -0
- package/dist/store/refresh-lock.js +125 -0
- package/dist/store/refresh.d.ts +48 -0
- package/dist/store/refresh.js +169 -0
- package/dist/store/rows.d.ts +53 -0
- package/dist/store/rows.js +250 -0
- package/dist/store/runtime.d.ts +28 -0
- package/dist/store/runtime.js +45 -0
- package/dist/store/schema.d.ts +133 -0
- package/dist/store/schema.js +323 -0
- package/package.json +13 -1
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { type PoolFailurePhase, type PoolOperation, PoolOperationError } from './errors.js';
|
|
2
|
+
import { type PoolLogger } from './hooks.js';
|
|
3
|
+
import { type LockEnvironment, LockStack, type PoolLockOptions, type PoolLockSpec } from './refresh-lock.js';
|
|
4
|
+
import { type PoolRow, type QuotaCodec, type StoredCredential } from './schema.js';
|
|
5
|
+
/** Named points on the write path, for crash and ownership injection. */
|
|
6
|
+
export type WriteStep = 'before-config-write' | 'after-config-write' | 'before-state-write' | 'after-state-write';
|
|
7
|
+
/** Awaitable pause points on the pull and refresh paths. */
|
|
8
|
+
export type HoldPoint = 'refresh-before-provider' | 'pull-before-request';
|
|
9
|
+
export interface StoreContext {
|
|
10
|
+
provider: string;
|
|
11
|
+
configPath: string;
|
|
12
|
+
statePath: string;
|
|
13
|
+
codec: QuotaCodec;
|
|
14
|
+
now: () => number;
|
|
15
|
+
storeLocks: readonly PoolLockSpec[];
|
|
16
|
+
lockDefaults: PoolLockOptions;
|
|
17
|
+
lockEnv: LockEnvironment;
|
|
18
|
+
logger?: PoolLogger;
|
|
19
|
+
onStep?: (step: WriteStep, info: {
|
|
20
|
+
operation: PoolOperation;
|
|
21
|
+
rowId: string | undefined;
|
|
22
|
+
}) => void | Promise<void>;
|
|
23
|
+
hold?: (point: HoldPoint, rowId: string) => void | Promise<void>;
|
|
24
|
+
/** Ids whose per-row entry a library write dropped in this process. */
|
|
25
|
+
removedIds: Set<string>;
|
|
26
|
+
}
|
|
27
|
+
export interface Snapshot {
|
|
28
|
+
configExists: boolean;
|
|
29
|
+
stateExists: boolean;
|
|
30
|
+
config: Record<string, unknown>;
|
|
31
|
+
state: Record<string, unknown>;
|
|
32
|
+
rows: PoolRow[];
|
|
33
|
+
}
|
|
34
|
+
export type ReadResult = ({
|
|
35
|
+
status: 'ready';
|
|
36
|
+
} & Snapshot) | {
|
|
37
|
+
status: 'pending-migration';
|
|
38
|
+
config: Record<string, unknown>;
|
|
39
|
+
} | {
|
|
40
|
+
status: 'error';
|
|
41
|
+
file: 'config' | 'state';
|
|
42
|
+
reason: string;
|
|
43
|
+
};
|
|
44
|
+
/** Reads and classifies both files. Never writes. */
|
|
45
|
+
export declare function readPool(ctx: StoreContext): Promise<ReadResult>;
|
|
46
|
+
/** The refusal for a pool that is not ready, as a failure value. */
|
|
47
|
+
export declare function notReadyError(result: Exclude<ReadResult, {
|
|
48
|
+
status: 'ready';
|
|
49
|
+
}>, operation: PoolOperation, rowId: string | undefined, phase?: PoolFailurePhase): PoolOperationError;
|
|
50
|
+
/** What an operation has written so far; decides the failure phase. */
|
|
51
|
+
export interface Progress {
|
|
52
|
+
writes: number;
|
|
53
|
+
/** The credential the operation's state write put on disk, once it has. */
|
|
54
|
+
committed?: StoredCredential;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* One locked read-modify-write. The store locks are pushed onto the caller's
|
|
58
|
+
* lock stack, so the ownership assertion before each write covers the outer
|
|
59
|
+
* row, provider-wide and extra locks as well; they are released when the
|
|
60
|
+
* transaction ends, whatever happens.
|
|
61
|
+
*/
|
|
62
|
+
export declare class Transaction {
|
|
63
|
+
private readonly ctx;
|
|
64
|
+
readonly snapshot: Snapshot;
|
|
65
|
+
private readonly locks;
|
|
66
|
+
private readonly progress;
|
|
67
|
+
private readonly info;
|
|
68
|
+
config: Record<string, unknown>;
|
|
69
|
+
state: Record<string, unknown>;
|
|
70
|
+
constructor(ctx: StoreContext, snapshot: Snapshot, locks: LockStack, progress: Progress, info: {
|
|
71
|
+
operation: PoolOperation;
|
|
72
|
+
rowId: string | undefined;
|
|
73
|
+
});
|
|
74
|
+
rows(): PoolRow[];
|
|
75
|
+
row(id: string): PoolRow | undefined;
|
|
76
|
+
roster(): unknown[];
|
|
77
|
+
/** The first roster row with this id (the one the pool loads). */
|
|
78
|
+
rosterRow(id: string): Record<string, unknown> | undefined;
|
|
79
|
+
entries(): Record<string, unknown>;
|
|
80
|
+
entry(id: string): Record<string, unknown> | undefined;
|
|
81
|
+
setEntry(id: string, entry: Record<string, unknown>): void;
|
|
82
|
+
stateAccount(id: string): Record<string, unknown> | undefined;
|
|
83
|
+
setStateAccount(id: string, fields: Record<string, unknown>): void;
|
|
84
|
+
/**
|
|
85
|
+
* Writes the config: legacy `version: 1` and the legacy roster beside
|
|
86
|
+
* `commonAuthPool`, every other top-level key and every unrecognised pool
|
|
87
|
+
* key untouched. Entries for ids no longer in the roster are dropped here,
|
|
88
|
+
* and remembered so the id is not reused in this process.
|
|
89
|
+
*/
|
|
90
|
+
commitConfig(): Promise<void>;
|
|
91
|
+
/** Writes the state: every unrecognised top-level and per-row key kept. */
|
|
92
|
+
commitState(committed?: StoredCredential): Promise<void>;
|
|
93
|
+
private write;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Runs `fn` under the store-lock list. The pool must be ready: a pending
|
|
97
|
+
* migration or a load error refuses before anything is written.
|
|
98
|
+
*/
|
|
99
|
+
export declare function withTransaction<T>(ctx: StoreContext, locks: LockStack, progress: Progress, info: {
|
|
100
|
+
operation: PoolOperation;
|
|
101
|
+
rowId: string | undefined;
|
|
102
|
+
}, fn: (tx: Transaction) => Promise<T>): Promise<T>;
|
|
103
|
+
/** Maps anything thrown inside an operation onto the one failure value. */
|
|
104
|
+
export declare function toFailure(error: unknown, operation: PoolOperation, rowId: string | undefined, progress: Progress): PoolOperationError;
|
|
105
|
+
/**
|
|
106
|
+
* The frame every lock-holding operation runs in: failures are mapped onto
|
|
107
|
+
* the failure value, handed to the failure hook while the outer locks are
|
|
108
|
+
* still held (the store locks are already released), and rethrown; every
|
|
109
|
+
* lock is released afterwards.
|
|
110
|
+
*/
|
|
111
|
+
export declare function runOperation<T>(ctx: StoreContext, operation: PoolOperation, rowId: string, onFailure: ((rowId: string, error: PoolOperationError) => void | Promise<void>) | undefined, body: (locks: LockStack, progress: Progress) => Promise<T>): Promise<T>;
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { writeJsonAtomic } from '../fs/atomic-write.js';
|
|
3
|
+
import { LockContentionError, LockOwnershipError } from '../fs/with-lock.js';
|
|
4
|
+
import { PoolOperationError, } from './errors.js';
|
|
5
|
+
import { callFailureHook } from './hooks.js';
|
|
6
|
+
import { LockStack, } from './refresh-lock.js';
|
|
7
|
+
import { buildRows, classifyConfig, classifyState, isRecord, LEGACY_STORE_VERSION, POOL_KEY, POOL_ROWS_KEY, POOL_SCHEMA_VERSION, } from './schema.js';
|
|
8
|
+
async function readJson(path) {
|
|
9
|
+
let text;
|
|
10
|
+
try {
|
|
11
|
+
text = await readFile(path, 'utf8');
|
|
12
|
+
}
|
|
13
|
+
catch (error) {
|
|
14
|
+
if (error.code === 'ENOENT')
|
|
15
|
+
return { exists: false };
|
|
16
|
+
throw error;
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
return { exists: true, value: JSON.parse(text) };
|
|
20
|
+
}
|
|
21
|
+
catch (parseError) {
|
|
22
|
+
return { exists: true, parseError };
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** Reads and classifies both files. Never writes. */
|
|
26
|
+
export async function readPool(ctx) {
|
|
27
|
+
const config = classifyConfig(await readJson(ctx.configPath));
|
|
28
|
+
if (config.status === 'error')
|
|
29
|
+
return { status: 'error', file: 'config', reason: config.reason };
|
|
30
|
+
const state = classifyState(await readJson(ctx.statePath));
|
|
31
|
+
if (state.status === 'error')
|
|
32
|
+
return { status: 'error', file: 'state', reason: state.reason };
|
|
33
|
+
if (config.status === 'pending-migration')
|
|
34
|
+
return { status: 'pending-migration', config: config.config };
|
|
35
|
+
return {
|
|
36
|
+
status: 'ready',
|
|
37
|
+
configExists: config.exists,
|
|
38
|
+
stateExists: state.exists,
|
|
39
|
+
config: config.config,
|
|
40
|
+
state: state.state,
|
|
41
|
+
rows: buildRows(config.config, state.state, ctx.codec),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/** The refusal for a pool that is not ready, as a failure value. */
|
|
45
|
+
export function notReadyError(result, operation, rowId, phase = 'before-first-write') {
|
|
46
|
+
return new PoolOperationError({
|
|
47
|
+
operation,
|
|
48
|
+
...(rowId !== undefined ? { rowId } : {}),
|
|
49
|
+
phase,
|
|
50
|
+
retryable: false,
|
|
51
|
+
kind: result.status === 'pending-migration'
|
|
52
|
+
? 'pending-migration'
|
|
53
|
+
: 'load-error',
|
|
54
|
+
message: result.status === 'pending-migration'
|
|
55
|
+
? 'the config holds a legacy roster that has not been migrated into the pool'
|
|
56
|
+
: `${result.file} file cannot be loaded: ${result.reason}`,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* One locked read-modify-write. The store locks are pushed onto the caller's
|
|
61
|
+
* lock stack, so the ownership assertion before each write covers the outer
|
|
62
|
+
* row, provider-wide and extra locks as well; they are released when the
|
|
63
|
+
* transaction ends, whatever happens.
|
|
64
|
+
*/
|
|
65
|
+
export class Transaction {
|
|
66
|
+
ctx;
|
|
67
|
+
snapshot;
|
|
68
|
+
locks;
|
|
69
|
+
progress;
|
|
70
|
+
info;
|
|
71
|
+
config;
|
|
72
|
+
state;
|
|
73
|
+
constructor(ctx, snapshot, locks, progress, info) {
|
|
74
|
+
this.ctx = ctx;
|
|
75
|
+
this.snapshot = snapshot;
|
|
76
|
+
this.locks = locks;
|
|
77
|
+
this.progress = progress;
|
|
78
|
+
this.info = info;
|
|
79
|
+
this.config = structuredClone(snapshot.config);
|
|
80
|
+
this.state = structuredClone(snapshot.state);
|
|
81
|
+
}
|
|
82
|
+
rows() {
|
|
83
|
+
return buildRows(this.config, this.state, this.ctx.codec);
|
|
84
|
+
}
|
|
85
|
+
row(id) {
|
|
86
|
+
return this.rows().find((row) => row.id === id);
|
|
87
|
+
}
|
|
88
|
+
roster() {
|
|
89
|
+
if (!Array.isArray(this.config.accounts))
|
|
90
|
+
this.config.accounts = [];
|
|
91
|
+
return this.config.accounts;
|
|
92
|
+
}
|
|
93
|
+
/** The first roster row with this id (the one the pool loads). */
|
|
94
|
+
rosterRow(id) {
|
|
95
|
+
return this.roster().find((raw) => isRecord(raw) && raw.id === id);
|
|
96
|
+
}
|
|
97
|
+
entries() {
|
|
98
|
+
if (!isRecord(this.config[POOL_KEY]))
|
|
99
|
+
this.config[POOL_KEY] = {};
|
|
100
|
+
const pool = this.config[POOL_KEY];
|
|
101
|
+
if (!isRecord(pool[POOL_ROWS_KEY]))
|
|
102
|
+
pool[POOL_ROWS_KEY] = {};
|
|
103
|
+
return pool[POOL_ROWS_KEY];
|
|
104
|
+
}
|
|
105
|
+
entry(id) {
|
|
106
|
+
const entries = this.entries();
|
|
107
|
+
const entry = Object.hasOwn(entries, id) ? entries[id] : undefined;
|
|
108
|
+
return isRecord(entry) ? entry : undefined;
|
|
109
|
+
}
|
|
110
|
+
setEntry(id, entry) {
|
|
111
|
+
Object.defineProperty(this.entries(), id, {
|
|
112
|
+
value: entry,
|
|
113
|
+
enumerable: true,
|
|
114
|
+
writable: true,
|
|
115
|
+
configurable: true,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
stateAccount(id) {
|
|
119
|
+
const accounts = isRecord(this.state.accounts) ? this.state.accounts : {};
|
|
120
|
+
const entry = Object.hasOwn(accounts, id) ? accounts[id] : undefined;
|
|
121
|
+
return isRecord(entry) ? entry : undefined;
|
|
122
|
+
}
|
|
123
|
+
setStateAccount(id, fields) {
|
|
124
|
+
if (!isRecord(this.state.accounts))
|
|
125
|
+
this.state.accounts = {};
|
|
126
|
+
Object.defineProperty(this.state.accounts, id, {
|
|
127
|
+
value: fields,
|
|
128
|
+
enumerable: true,
|
|
129
|
+
writable: true,
|
|
130
|
+
configurable: true,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Writes the config: legacy `version: 1` and the legacy roster beside
|
|
135
|
+
* `commonAuthPool`, every other top-level key and every unrecognised pool
|
|
136
|
+
* key untouched. Entries for ids no longer in the roster are dropped here,
|
|
137
|
+
* and remembered so the id is not reused in this process.
|
|
138
|
+
*/
|
|
139
|
+
async commitConfig() {
|
|
140
|
+
const roster = this.roster();
|
|
141
|
+
const rosterIds = new Set();
|
|
142
|
+
for (const raw of roster)
|
|
143
|
+
if (isRecord(raw) && typeof raw.id === 'string')
|
|
144
|
+
rosterIds.add(raw.id);
|
|
145
|
+
const entries = this.entries();
|
|
146
|
+
const kept = {};
|
|
147
|
+
for (const [id, entry] of Object.entries(entries)) {
|
|
148
|
+
if (rosterIds.has(id)) {
|
|
149
|
+
Object.defineProperty(kept, id, {
|
|
150
|
+
value: entry,
|
|
151
|
+
enumerable: true,
|
|
152
|
+
writable: true,
|
|
153
|
+
configurable: true,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
this.ctx.removedIds.add(id);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const pool = this.config[POOL_KEY];
|
|
161
|
+
const next = {
|
|
162
|
+
...this.config,
|
|
163
|
+
version: LEGACY_STORE_VERSION,
|
|
164
|
+
accounts: roster,
|
|
165
|
+
[POOL_KEY]: {
|
|
166
|
+
...pool,
|
|
167
|
+
schemaVersion: POOL_SCHEMA_VERSION,
|
|
168
|
+
[POOL_ROWS_KEY]: kept,
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
await this.write(this.ctx.configPath, next, 'config');
|
|
172
|
+
this.config = next;
|
|
173
|
+
}
|
|
174
|
+
/** Writes the state: every unrecognised top-level and per-row key kept. */
|
|
175
|
+
async commitState(committed) {
|
|
176
|
+
const next = {
|
|
177
|
+
...this.state,
|
|
178
|
+
version: LEGACY_STORE_VERSION,
|
|
179
|
+
accounts: isRecord(this.state.accounts) ? this.state.accounts : {},
|
|
180
|
+
};
|
|
181
|
+
await this.write(this.ctx.statePath, next, 'state');
|
|
182
|
+
this.state = next;
|
|
183
|
+
if (committed)
|
|
184
|
+
this.progress.committed = committed;
|
|
185
|
+
}
|
|
186
|
+
async write(path, value, file) {
|
|
187
|
+
await writeJsonAtomic(path, value, {
|
|
188
|
+
beforeRename: async () => {
|
|
189
|
+
await this.ctx.onStep?.(`before-${file}-write`, this.info);
|
|
190
|
+
// Ownership is proved immediately before the rename, on every lease.
|
|
191
|
+
await this.locks.assertAll();
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
this.progress.writes++;
|
|
195
|
+
await this.ctx.onStep?.(`after-${file}-write`, this.info);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Runs `fn` under the store-lock list. The pool must be ready: a pending
|
|
200
|
+
* migration or a load error refuses before anything is written.
|
|
201
|
+
*/
|
|
202
|
+
export async function withTransaction(ctx, locks, progress, info, fn) {
|
|
203
|
+
const mark = locks.held.length;
|
|
204
|
+
try {
|
|
205
|
+
for (const spec of ctx.storeLocks)
|
|
206
|
+
await locks.acquire(spec);
|
|
207
|
+
const result = await readPool(ctx);
|
|
208
|
+
if (result.status !== 'ready')
|
|
209
|
+
throw notReadyError(result, info.operation, info.rowId, progress.writes > 0 ? 'after-first-write' : 'before-first-write');
|
|
210
|
+
return await fn(new Transaction(ctx, result, locks, progress, info));
|
|
211
|
+
}
|
|
212
|
+
finally {
|
|
213
|
+
await locks.releaseTo(mark);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
/** Maps anything thrown inside an operation onto the one failure value. */
|
|
217
|
+
export function toFailure(error, operation, rowId, progress) {
|
|
218
|
+
const phase = operation === 'pull'
|
|
219
|
+
? 'pull'
|
|
220
|
+
: progress.writes > 0
|
|
221
|
+
? 'after-first-write'
|
|
222
|
+
: 'before-first-write';
|
|
223
|
+
const committed = phase === 'after-first-write' ? progress.committed : undefined;
|
|
224
|
+
if (error instanceof PoolOperationError) {
|
|
225
|
+
if (error.phase === phase &&
|
|
226
|
+
error.rowId === rowId &&
|
|
227
|
+
error.operation === operation &&
|
|
228
|
+
error.committed === committed)
|
|
229
|
+
return error;
|
|
230
|
+
return new PoolOperationError({
|
|
231
|
+
operation,
|
|
232
|
+
...(rowId !== undefined ? { rowId } : {}),
|
|
233
|
+
phase: error.kind === 'after-persist-hook' ? error.phase : phase,
|
|
234
|
+
retryable: error.retryable,
|
|
235
|
+
kind: error.kind,
|
|
236
|
+
...((error.committed ?? committed)
|
|
237
|
+
? { committed: error.committed ?? committed }
|
|
238
|
+
: {}),
|
|
239
|
+
message: error.message,
|
|
240
|
+
...(error.cause !== undefined ? { cause: error.cause } : {}),
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
const base = {
|
|
244
|
+
operation,
|
|
245
|
+
...(rowId !== undefined ? { rowId } : {}),
|
|
246
|
+
phase,
|
|
247
|
+
...(committed ? { committed } : {}),
|
|
248
|
+
cause: error,
|
|
249
|
+
};
|
|
250
|
+
if (error instanceof LockOwnershipError)
|
|
251
|
+
return new PoolOperationError({
|
|
252
|
+
...base,
|
|
253
|
+
retryable: true,
|
|
254
|
+
kind: 'lock-ownership',
|
|
255
|
+
message: phase === 'after-first-write'
|
|
256
|
+
? `${operation} lost a lease after its first write; the intermediate stays on disk`
|
|
257
|
+
: `${operation} lost a lease before writing; nothing was written`,
|
|
258
|
+
});
|
|
259
|
+
if (error instanceof LockContentionError)
|
|
260
|
+
return new PoolOperationError({
|
|
261
|
+
...base,
|
|
262
|
+
retryable: true,
|
|
263
|
+
kind: 'lock-contention',
|
|
264
|
+
message: error.message,
|
|
265
|
+
});
|
|
266
|
+
return new PoolOperationError({
|
|
267
|
+
...base,
|
|
268
|
+
retryable: false,
|
|
269
|
+
kind: 'unexpected',
|
|
270
|
+
message: error instanceof Error ? error.message : String(error),
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* The frame every lock-holding operation runs in: failures are mapped onto
|
|
275
|
+
* the failure value, handed to the failure hook while the outer locks are
|
|
276
|
+
* still held (the store locks are already released), and rethrown; every
|
|
277
|
+
* lock is released afterwards.
|
|
278
|
+
*/
|
|
279
|
+
export async function runOperation(ctx, operation, rowId, onFailure, body) {
|
|
280
|
+
const locks = new LockStack(ctx.lockDefaults, ctx.lockEnv);
|
|
281
|
+
const progress = { writes: 0 };
|
|
282
|
+
try {
|
|
283
|
+
return await body(locks, progress);
|
|
284
|
+
}
|
|
285
|
+
catch (error) {
|
|
286
|
+
const failure = toFailure(error, operation, rowId, progress);
|
|
287
|
+
if (failure.kind !== 'after-persist-hook')
|
|
288
|
+
await callFailureHook(operation, onFailure, rowId, failure, ctx.logger);
|
|
289
|
+
throw failure;
|
|
290
|
+
}
|
|
291
|
+
finally {
|
|
292
|
+
await locks.releaseAll();
|
|
293
|
+
}
|
|
294
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { type Attribution } from './attribution.js';
|
|
2
|
+
import type { PoolOperationError } from './errors.js';
|
|
3
|
+
import type { PoolLogger } from './hooks.js';
|
|
4
|
+
import { type HoldPoint, type StoreContext } from './mutate.js';
|
|
5
|
+
import { type PullHook } from './pull.js';
|
|
6
|
+
import { type ProviderRefresh, type RefreshOptions, type RefreshOutcome } from './refresh.js';
|
|
7
|
+
import { type LockEnvironment, type PoolLockOptions, type PoolLockSpec } from './refresh-lock.js';
|
|
8
|
+
import { type AddInput, type AddResult, type RowOperationOptions } from './rows.js';
|
|
9
|
+
import { type PoolCredential, type PoolRow, type QuotaCodec, type StoredCredential } from './schema.js';
|
|
10
|
+
export interface OpenPoolStoreOptions {
|
|
11
|
+
/** The provider every row of this pool belongs to; keys the provider-wide lock. */
|
|
12
|
+
provider: string;
|
|
13
|
+
configPath: string;
|
|
14
|
+
statePath: string;
|
|
15
|
+
quota: QuotaCodec;
|
|
16
|
+
/** Injected clock for leases, refresh stamps and `addedAt`. */
|
|
17
|
+
now?: () => number;
|
|
18
|
+
/**
|
|
19
|
+
* Ordered store-lock list held across every read-modify-write. Defaults to
|
|
20
|
+
* the older writers' `save` lock at the config path, then at the state path.
|
|
21
|
+
*/
|
|
22
|
+
storeLocks?: readonly PoolLockSpec[];
|
|
23
|
+
/** Defaults for every lock the store takes (see `POOL_LOCK_DEFAULTS`). */
|
|
24
|
+
lockOptions?: Partial<PoolLockOptions>;
|
|
25
|
+
/** Overrides for the row locks only. */
|
|
26
|
+
rowLockOptions?: Partial<PoolLockOptions>;
|
|
27
|
+
/** The provider-wide lock; defaults to `provider-<provider>` beside the state file. */
|
|
28
|
+
providerLock?: PoolLockSpec;
|
|
29
|
+
/** Quota pull hook, fired without being awaited. */
|
|
30
|
+
pull?: PullHook;
|
|
31
|
+
/** Receives every pull failure, since no caller awaits a pull. */
|
|
32
|
+
onPullFailure?: (rowId: string, error: PoolOperationError) => void | Promise<void>;
|
|
33
|
+
logger?: PoolLogger;
|
|
34
|
+
/** Named write steps, awaited; a test seam for crash and ownership rows. */
|
|
35
|
+
onStep?: StoreContext['onStep'];
|
|
36
|
+
/** Awaitable hold points on the refresh and pull paths; a test seam. */
|
|
37
|
+
hold?: (point: HoldPoint, rowId: string) => void | Promise<void>;
|
|
38
|
+
onLockEvent?: LockEnvironment['onLockEvent'];
|
|
39
|
+
onLockStep?: LockEnvironment['onLockStep'];
|
|
40
|
+
}
|
|
41
|
+
export type PoolLoad = {
|
|
42
|
+
status: 'ready';
|
|
43
|
+
schemaVersion: number;
|
|
44
|
+
rows: PoolRow[];
|
|
45
|
+
} | {
|
|
46
|
+
status: 'pending-migration';
|
|
47
|
+
roster: unknown[];
|
|
48
|
+
} | {
|
|
49
|
+
status: 'error';
|
|
50
|
+
file: 'config' | 'state';
|
|
51
|
+
reason: string;
|
|
52
|
+
};
|
|
53
|
+
export interface PoolStore {
|
|
54
|
+
/** Reads the pool and fires first-reading pulls; never writes a file itself. */
|
|
55
|
+
load(): Promise<PoolLoad>;
|
|
56
|
+
/** Reads the pool without firing anything. */
|
|
57
|
+
read(): Promise<PoolLoad>;
|
|
58
|
+
add(input: AddInput, options?: RowOperationOptions): Promise<AddResult>;
|
|
59
|
+
replace(id: string, credential: PoolCredential, input?: {
|
|
60
|
+
identity?: string;
|
|
61
|
+
}, options?: RowOperationOptions): Promise<{
|
|
62
|
+
id: string;
|
|
63
|
+
credential: StoredCredential;
|
|
64
|
+
credentialEpoch: number;
|
|
65
|
+
}>;
|
|
66
|
+
rotate(id: string, credential: PoolCredential, input?: {
|
|
67
|
+
identity?: string;
|
|
68
|
+
}, options?: RowOperationOptions): Promise<{
|
|
69
|
+
id: string;
|
|
70
|
+
credential: StoredCredential;
|
|
71
|
+
}>;
|
|
72
|
+
disable(id: string, reason: string, options?: Pick<RowOperationOptions, 'onFailure'>): Promise<{
|
|
73
|
+
id: string;
|
|
74
|
+
}>;
|
|
75
|
+
recordIdentity(id: string, identity: string, options?: RowOperationOptions): Promise<{
|
|
76
|
+
id: string;
|
|
77
|
+
disabled: string[];
|
|
78
|
+
}>;
|
|
79
|
+
refresh(id: string, provider: ProviderRefresh, options?: RefreshOptions): Promise<RefreshOutcome>;
|
|
80
|
+
recordQuota(id: string, attribution: Attribution, observation: unknown): Promise<void>;
|
|
81
|
+
/** Fires a pull for a row admission refused for want of a reading. */
|
|
82
|
+
requestReading(id: string): void;
|
|
83
|
+
/** Resolves once every pull fired so far has settled. */
|
|
84
|
+
pullsSettled(): Promise<void>;
|
|
85
|
+
}
|
|
86
|
+
export declare function openPoolStore(options: OpenPoolStoreOptions): PoolStore;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { recordQuota } from './attribution.js';
|
|
2
|
+
import { readPool } from './mutate.js';
|
|
3
|
+
import { PullScheduler } from './pull.js';
|
|
4
|
+
import { refreshRow, } from './refresh.js';
|
|
5
|
+
import { POOL_LOCK_DEFAULTS, } from './refresh-lock.js';
|
|
6
|
+
import { addRow, disableRow, recordRowIdentity, replaceRow, rotateRow, } from './rows.js';
|
|
7
|
+
import { POOL_SCHEMA_VERSION, } from './schema.js';
|
|
8
|
+
/**
|
|
9
|
+
* Process-wide memory per config file: ids whose per-row entry a library
|
|
10
|
+
* write dropped (not reused in this process), and rows a load-time pull
|
|
11
|
+
* already fired for.
|
|
12
|
+
*/
|
|
13
|
+
const processMemory = new Map();
|
|
14
|
+
function memoryFor(configPath) {
|
|
15
|
+
let memory = processMemory.get(configPath);
|
|
16
|
+
if (!memory) {
|
|
17
|
+
memory = { removedIds: new Set(), firedAtLoad: new Set() };
|
|
18
|
+
processMemory.set(configPath, memory);
|
|
19
|
+
}
|
|
20
|
+
return memory;
|
|
21
|
+
}
|
|
22
|
+
function toLoad(result) {
|
|
23
|
+
if (result.status === 'ready')
|
|
24
|
+
return {
|
|
25
|
+
status: 'ready',
|
|
26
|
+
schemaVersion: POOL_SCHEMA_VERSION,
|
|
27
|
+
rows: result.rows,
|
|
28
|
+
};
|
|
29
|
+
if (result.status === 'pending-migration')
|
|
30
|
+
return {
|
|
31
|
+
status: 'pending-migration',
|
|
32
|
+
roster: Array.isArray(result.config.accounts)
|
|
33
|
+
? result.config.accounts
|
|
34
|
+
: [],
|
|
35
|
+
};
|
|
36
|
+
return result;
|
|
37
|
+
}
|
|
38
|
+
export function openPoolStore(options) {
|
|
39
|
+
const memory = memoryFor(options.configPath);
|
|
40
|
+
const lockDefaults = {
|
|
41
|
+
...POOL_LOCK_DEFAULTS,
|
|
42
|
+
...options.lockOptions,
|
|
43
|
+
};
|
|
44
|
+
const ctx = {
|
|
45
|
+
provider: options.provider,
|
|
46
|
+
configPath: options.configPath,
|
|
47
|
+
statePath: options.statePath,
|
|
48
|
+
codec: options.quota,
|
|
49
|
+
now: options.now ?? Date.now,
|
|
50
|
+
storeLocks: options.storeLocks ?? [
|
|
51
|
+
{ name: 'save', path: options.configPath },
|
|
52
|
+
{ name: 'save', path: options.statePath },
|
|
53
|
+
],
|
|
54
|
+
lockDefaults,
|
|
55
|
+
lockEnv: {
|
|
56
|
+
now: options.now ?? Date.now,
|
|
57
|
+
...(options.onLockEvent ? { onLockEvent: options.onLockEvent } : {}),
|
|
58
|
+
...(options.onLockStep ? { onLockStep: options.onLockStep } : {}),
|
|
59
|
+
},
|
|
60
|
+
removedIds: memory.removedIds,
|
|
61
|
+
...(options.logger ? { logger: options.logger } : {}),
|
|
62
|
+
...(options.onStep ? { onStep: options.onStep } : {}),
|
|
63
|
+
...(options.hold ? { hold: options.hold } : {}),
|
|
64
|
+
};
|
|
65
|
+
const pulls = new PullScheduler(() => rt, options.pull, options.onPullFailure, memory.firedAtLoad, options.logger);
|
|
66
|
+
const rt = {
|
|
67
|
+
ctx,
|
|
68
|
+
providerLock: options.providerLock ?? {
|
|
69
|
+
name: `provider-${encodeURIComponent(options.provider)}`,
|
|
70
|
+
path: options.statePath,
|
|
71
|
+
},
|
|
72
|
+
rowLockOptions: options.rowLockOptions ?? {},
|
|
73
|
+
firePull: (id, reason) => pulls.fire(id, reason),
|
|
74
|
+
};
|
|
75
|
+
return {
|
|
76
|
+
async load() {
|
|
77
|
+
const result = await readPool(ctx);
|
|
78
|
+
if (result.status === 'ready') {
|
|
79
|
+
for (const row of result.rows)
|
|
80
|
+
if (row.candidate && row.type === 'oauth' && row.needsFirstReading)
|
|
81
|
+
pulls.fire(row.id, 'load');
|
|
82
|
+
}
|
|
83
|
+
return toLoad(result);
|
|
84
|
+
},
|
|
85
|
+
async read() {
|
|
86
|
+
return toLoad(await readPool(ctx));
|
|
87
|
+
},
|
|
88
|
+
add: (input, callOptions) => addRow(rt, input, callOptions),
|
|
89
|
+
replace: (id, credential, input, callOptions) => replaceRow(rt, id, credential, input, callOptions),
|
|
90
|
+
rotate: (id, credential, input, callOptions) => rotateRow(rt, id, credential, input, callOptions),
|
|
91
|
+
disable: (id, reason, callOptions) => disableRow(rt, id, reason, callOptions),
|
|
92
|
+
recordIdentity: (id, identity, callOptions) => recordRowIdentity(rt, id, identity, callOptions),
|
|
93
|
+
refresh: (id, provider, callOptions) => refreshRow(rt, id, provider, callOptions),
|
|
94
|
+
recordQuota: (id, attribution, observation) => recordQuota(rt, id, attribution, observation),
|
|
95
|
+
requestReading: (id) => pulls.fire(id, 'admission'),
|
|
96
|
+
pullsSettled: () => pulls.settled(),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { PoolOperationError } from './errors.js';
|
|
2
|
+
import type { PoolLogger } from './hooks.js';
|
|
3
|
+
import type { PullReason, StoreRuntime } from './runtime.js';
|
|
4
|
+
import type { StoredCredential } from './schema.js';
|
|
5
|
+
/** What a pull is issued with: the credential and its attribution tuple. */
|
|
6
|
+
export interface PullRequest {
|
|
7
|
+
id: string;
|
|
8
|
+
credential: StoredCredential;
|
|
9
|
+
credentialEpoch: number;
|
|
10
|
+
identity?: string;
|
|
11
|
+
reason: PullReason;
|
|
12
|
+
}
|
|
13
|
+
export type PullHook = (request: PullRequest) => Promise<unknown>;
|
|
14
|
+
/**
|
|
15
|
+
* Fires quota pulls without ever making a caller wait for one. A pull first
|
|
16
|
+
* gives a row without a per-row entry its entry at epoch 1 (its own locked
|
|
17
|
+
* config write), then captures the credential and the attribution tuple in
|
|
18
|
+
* one locked read, requests the observation, and records it only if
|
|
19
|
+
* attribution still holds. Failures go to the store's pull failure hook.
|
|
20
|
+
*/
|
|
21
|
+
export declare class PullScheduler {
|
|
22
|
+
private readonly rt;
|
|
23
|
+
private readonly hook;
|
|
24
|
+
private readonly onFailure;
|
|
25
|
+
/** Rows a load-time pull has fired for in this process. */
|
|
26
|
+
private readonly firedAtLoad;
|
|
27
|
+
private readonly logger;
|
|
28
|
+
private readonly inflight;
|
|
29
|
+
constructor(rt: () => StoreRuntime, hook: PullHook | undefined, onFailure: ((rowId: string, error: PoolOperationError) => void | Promise<void>) | undefined,
|
|
30
|
+
/** Rows a load-time pull has fired for in this process. */
|
|
31
|
+
firedAtLoad: Set<string>, logger: PoolLogger | undefined);
|
|
32
|
+
fire(id: string, reason: PullReason): void;
|
|
33
|
+
/** Resolves once every pull fired so far has settled. */
|
|
34
|
+
settled(): Promise<void>;
|
|
35
|
+
private run;
|
|
36
|
+
}
|