@cortexkit/common-auth 0.2.9 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/dist/cachekeep/manager.d.ts +18 -6
  2. package/dist/cachekeep/manager.js +40 -10
  3. package/dist/claustrum/consumer.d.ts +13 -4
  4. package/dist/claustrum/consumer.js +11 -3
  5. package/dist/claustrum/custody.d.ts +47 -6
  6. package/dist/claustrum/custody.js +37 -7
  7. package/dist/claustrum/errors.d.ts +1 -1
  8. package/dist/claustrum/index.d.ts +3 -3
  9. package/dist/claustrum/index.js +2 -2
  10. package/dist/claustrum/interlock.d.ts +15 -17
  11. package/dist/claustrum/interlock.js +19 -26
  12. package/dist/claustrum/roster.d.ts +96 -6
  13. package/dist/claustrum/roster.js +209 -44
  14. package/dist/commands/builtins.d.ts +1 -1
  15. package/dist/commands/builtins.js +6 -1
  16. package/dist/commands/index.d.ts +2 -2
  17. package/dist/commands/index.js +1 -1
  18. package/dist/commands/menu.d.ts +8 -0
  19. package/dist/commands/menu.js +34 -13
  20. package/dist/commands/model.d.ts +9 -0
  21. package/dist/commands/seam.d.ts +40 -4
  22. package/dist/commands/seam.js +132 -19
  23. package/dist/dump/index.d.ts +94 -0
  24. package/dist/dump/index.js +236 -9
  25. package/dist/logger/engine.d.ts +52 -17
  26. package/dist/logger/engine.js +178 -135
  27. package/dist/logger/index.d.ts +2 -2
  28. package/dist/logger/index.js +1 -1
  29. package/dist/opencode2/index.d.ts +1 -1
  30. package/dist/opencode2/install.d.ts +23 -5
  31. package/dist/opencode2/install.js +335 -140
  32. package/dist/opencode2/types.d.ts +149 -9
  33. package/dist/quota/projection.d.ts +11 -4
  34. package/dist/quota/projection.js +11 -4
  35. package/dist/routing/admission.js +3 -1
  36. package/dist/routing/index.d.ts +2 -2
  37. package/dist/routing/index.js +1 -1
  38. package/dist/routing/sticky.d.ts +19 -6
  39. package/dist/routing/sticky.js +34 -23
  40. package/dist/rpc/notifications.d.ts +20 -0
  41. package/dist/rpc/notifications.js +21 -0
  42. package/dist/rpc/rpc-server.d.ts +9 -1
  43. package/dist/rpc/rpc-server.js +8 -1
  44. package/dist/sidebar-file/index.d.ts +1 -1
  45. package/dist/sidebar-file/sidebar-file.d.ts +50 -2
  46. package/dist/sidebar-file/sidebar-file.js +92 -21
  47. package/dist/store/attribution.js +11 -2
  48. package/dist/store/errors.d.ts +6 -3
  49. package/dist/store/identity.d.ts +13 -4
  50. package/dist/store/mutate.d.ts +23 -3
  51. package/dist/store/mutate.js +43 -26
  52. package/dist/store/pool.d.ts +8 -1
  53. package/dist/store/pool.js +7 -2
  54. package/dist/store/rows.d.ts +17 -4
  55. package/dist/store/rows.js +82 -33
  56. package/dist/store/schema.d.ts +57 -4
  57. package/dist/store/schema.js +100 -7
  58. package/dist/store/torn.d.ts +29 -0
  59. package/dist/store/torn.js +113 -0
  60. package/package.json +1 -1
@@ -3,7 +3,50 @@ export interface SidebarFileHooks {
3
3
  beforeRecheck?: () => void | Promise<void>;
4
4
  /** @internal Test seam after staging, before the ownership fence. */
5
5
  beforeCommit?: () => Promise<void>;
6
+ /** @internal Test seam after the rename, before ownership is checked again. */
7
+ afterRename?: () => Promise<void>;
6
8
  }
9
+ /**
10
+ * Rebuild a write after the lock was lost while it was being renamed into
11
+ * place. `current` is the file as it is now, under a freshly taken lock: a
12
+ * successor may have written it after taking over the lease. `written` is the
13
+ * value this write committed. Return the value to write instead, typically
14
+ * the successor's state with only the fields this write is the authority on
15
+ * carried over, or undefined to leave the file as it is.
16
+ */
17
+ export type SidebarRepair<T> = (current: T, written: T) => T | undefined;
18
+ export interface SidebarWriteOptions<T> extends SidebarFileHooks {
19
+ /** Repair for this write; overrides the file's `repair` option. */
20
+ repair?: SidebarRepair<T>;
21
+ /**
22
+ * Told how this write ended, before its promise resolves. A callback
23
+ * rather than a resolved value, so `write` and `update` keep resolving to
24
+ * nothing for callers that pass them on as `Promise<void>`.
25
+ */
26
+ onResult?: (result: SidebarWriteResult) => void;
27
+ }
28
+ /**
29
+ * How a write ended.
30
+ *
31
+ * - `skipped`: the merge returned undefined, so nothing was written.
32
+ * - `written`: the value was renamed into place and the lock was still held
33
+ * afterwards, so no other writer can have taken over during the rename.
34
+ * - `lost-after-rename`: the value was renamed into place, but by then the
35
+ * lock had been taken over; the value may have replaced a successor's
36
+ * state. `repair` says what was done about it: `none` (no repair was
37
+ * supplied), `written` (the repaired value was committed and the lock held),
38
+ * `skipped` (the repair returned undefined), `lock-unavailable` (the lock
39
+ * could not be retaken in time) or `lost-again` (the repaired value was
40
+ * committed but the lock was lost again; no further repair is tried).
41
+ */
42
+ export type SidebarWriteResult = {
43
+ status: 'skipped';
44
+ } | {
45
+ status: 'written';
46
+ } | {
47
+ status: 'lost-after-rename';
48
+ repair: 'none' | 'written' | 'skipped' | 'lock-unavailable' | 'lost-again';
49
+ };
7
50
  export interface SidebarFileOptions<T> {
8
51
  path: string;
9
52
  defaultValue: T;
@@ -20,11 +63,16 @@ export interface SidebarFileOptions<T> {
20
63
  warn: (message: string, payload?: unknown) => void;
21
64
  debug: (message: string, payload?: unknown) => void;
22
65
  };
66
+ /**
67
+ * Runs once, under a newly taken lock, when a write finds after its rename
68
+ * that the lock was lost. Without it such a write is only reported.
69
+ */
70
+ repair?: SidebarRepair<T>;
23
71
  }
24
72
  export interface SidebarFile<T> {
25
73
  read(): Promise<T>;
26
- write(value: T, hooks?: SidebarFileHooks): Promise<void>;
27
- update(merge: (latest: T) => T | undefined, hooks?: SidebarFileHooks): Promise<void>;
74
+ write(value: T, options?: SidebarWriteOptions<T>): Promise<void>;
75
+ update(merge: (latest: T) => T | undefined, options?: SidebarWriteOptions<T>): Promise<void>;
28
76
  }
29
77
  /** Binds generic persistence to a caller's path and normalization policy. */
30
78
  export declare function createSidebarFile<T>(options: SidebarFileOptions<T>): SidebarFile<T>;
@@ -1,6 +1,6 @@
1
1
  import { chmod, mkdir, readFile } from 'node:fs/promises';
2
2
  import { dirname } from 'node:path';
3
- import { WRITER_LOCK_CONSTANTS, withLock, writeJsonAtomic, } from '../fs/index.js';
3
+ import { LockContentionError, LockOwnershipError, WRITER_LOCK_CONSTANTS, withLock, writeJsonAtomic, } from '../fs/index.js';
4
4
  /** Binds generic persistence to a caller's path and normalization policy. */
5
5
  export function createSidebarFile(options) {
6
6
  const { path, defaultValue, normalize } = options;
@@ -33,13 +33,79 @@ export function createSidebarFile(options) {
33
33
  return defaultValue;
34
34
  }
35
35
  };
36
- const enqueue = (operation) => {
37
- const result = chain.then(operation);
36
+ const enqueue = (operation, writeOptions) => {
37
+ const result = chain
38
+ .then(operation)
39
+ .then((outcome) => writeOptions?.onResult?.(outcome));
38
40
  // Keep the next operation runnable without hiding this caller's rejection.
39
41
  chain = result.catch(() => { });
40
42
  return result;
41
43
  };
42
- const persist = async (merge, hooks) => {
44
+ const lockOptions = {
45
+ ...WRITER_LOCK_CONSTANTS.sidebar,
46
+ timeoutMs: options.timeoutMs ?? WRITER_LOCK_CONSTANTS.sidebar.timeoutMs,
47
+ };
48
+ /**
49
+ * Rename `value` into place under `lock`, then report whether the lock was
50
+ * still held. The fence before the rename stops a write whose lock is
51
+ * already gone; the check after it catches a lock lost while the rename
52
+ * itself was in flight, which no check before it can see.
53
+ */
54
+ const commitUnder = async (lock, value, hooks) => {
55
+ await writeJsonAtomic(path, value, {
56
+ serialize: JSON.stringify,
57
+ beforeRename: async () => {
58
+ await hooks?.beforeCommit?.();
59
+ await lock.assertOwned();
60
+ },
61
+ });
62
+ await hooks?.afterRename?.();
63
+ try {
64
+ await lock.assertOwned();
65
+ return true;
66
+ }
67
+ catch (error) {
68
+ if (error instanceof LockOwnershipError)
69
+ return false;
70
+ throw error;
71
+ }
72
+ };
73
+ /**
74
+ * One repair, under a new lock, of a write that lost its lock during the
75
+ * rename. Bounded to a single attempt: if the repair loses its lock too,
76
+ * the newer holder is writing and its state stands.
77
+ */
78
+ const repairLostWrite = async (repair, written, hooks) => {
79
+ options.logger?.warn('sidebar lock lost after rename; repairing write', {
80
+ path,
81
+ });
82
+ try {
83
+ const outcome = await withLock(path, lockOptions, async (lock) => {
84
+ const next = repair(await read(), written);
85
+ if (next === undefined)
86
+ return 'skipped';
87
+ return (await commitUnder(lock, next, hooks))
88
+ ? 'written'
89
+ : 'lost-again';
90
+ });
91
+ if (outcome === 'lost-again') {
92
+ options.logger?.warn('sidebar repair lost its lock after rename', {
93
+ path,
94
+ });
95
+ }
96
+ return { status: 'lost-after-rename', repair: outcome };
97
+ }
98
+ catch (error) {
99
+ if (!(error instanceof LockContentionError))
100
+ throw error;
101
+ options.logger?.warn('sidebar repair lock unavailable; repair skipped', {
102
+ path,
103
+ });
104
+ return { status: 'lost-after-rename', repair: 'lock-unavailable' };
105
+ }
106
+ };
107
+ const persist = async (merge, writeOptions) => {
108
+ const hooks = writeOptions;
43
109
  const parent = dirname(path);
44
110
  const secureDir = options.secureDir ?? true;
45
111
  await mkdir(parent, { recursive: true, mode: 0o700 });
@@ -48,38 +114,43 @@ export function createSidebarFile(options) {
48
114
  options.logger?.warn('sidebar directory permission remediation failed', { error: error instanceof Error ? error.message : String(error) });
49
115
  });
50
116
  }
51
- await withLock(path, {
52
- ...WRITER_LOCK_CONSTANTS.sidebar,
53
- timeoutMs: options.timeoutMs ?? WRITER_LOCK_CONSTANTS.sidebar.timeoutMs,
54
- }, async (lock) => {
55
- const commit = (value) => writeJsonAtomic(path, value, {
56
- serialize: JSON.stringify,
57
- beforeRename: async () => {
58
- await hooks?.beforeCommit?.();
59
- await lock.assertOwned();
60
- },
117
+ const committed = await withLock(path, lockOptions, async (lock) => {
118
+ const commit = async (value) => ({
119
+ value,
120
+ held: await commitUnder(lock, value, hooks),
61
121
  });
62
122
  // Older processes may ignore the lock; remerge if their bytes changed.
63
123
  for (let attempt = 0; attempt < 3; attempt += 1) {
64
124
  const raw = await readRaw();
65
125
  const next = merge(parse(raw));
66
126
  if (next === undefined)
67
- return;
127
+ return undefined;
68
128
  if (attempt === 0)
69
129
  await hooks?.beforeRecheck?.();
70
130
  if ((await readRaw()) !== raw)
71
131
  continue;
72
- await commit(next);
73
- return;
132
+ return await commit(next);
74
133
  }
75
134
  const next = merge(await read());
76
- if (next !== undefined)
77
- await commit(next);
135
+ return next === undefined ? undefined : await commit(next);
78
136
  });
137
+ if (committed === undefined)
138
+ return { status: 'skipped' };
139
+ if (committed.held)
140
+ return { status: 'written' };
141
+ // The first lock is released by now; the repair takes its own.
142
+ const repair = writeOptions?.repair ?? options.repair;
143
+ if (!repair) {
144
+ options.logger?.warn('sidebar lock lost after rename; write not repaired', {
145
+ path,
146
+ });
147
+ return { status: 'lost-after-rename', repair: 'none' };
148
+ }
149
+ return await repairLostWrite(repair, committed.value, hooks);
79
150
  };
80
151
  return {
81
152
  read,
82
- write: (value, hooks) => enqueue(() => persist(() => value, hooks)),
83
- update: (merge, hooks) => enqueue(() => persist(merge, hooks)),
153
+ write: (value, writeOptions) => enqueue(() => persist(() => value, writeOptions), writeOptions),
154
+ update: (merge, writeOptions) => enqueue(() => persist(merge, writeOptions), writeOptions),
84
155
  };
85
156
  }
@@ -18,8 +18,14 @@ export async function recordQuota(rt, id, attribution, observation) {
18
18
  throw unknownRow('pull', id);
19
19
  if (row.invalid)
20
20
  throw refusal('pull', id, 'invalid-row', `row ${id} is invalid`);
21
+ // A reading belongs to one (epoch, identity, credential). A row
22
+ // holding no credential, or torn between the writes of a replace,
23
+ // has no such triple on disk, so no reading is recorded for it.
24
+ if (!row.credential)
25
+ throw refusal('pull', id, 'no-credential', `row ${id} holds no credential`);
21
26
  const entry = tx.entry(id);
22
- if (!entry ||
27
+ if (row.torn ||
28
+ !entry ||
23
29
  entry.credentialEpoch !== attribution.credentialEpoch ||
24
30
  row.identity !== attribution.identity)
25
31
  throw new PoolOperationError({
@@ -35,7 +41,10 @@ export async function recordQuota(rt, id, attribution, observation) {
35
41
  throw refusal('pull', id, 'invalid-quota', 'the quota codec rejected the merged map');
36
42
  tx.setEntry(id, { ...entry, quota: merged, needsFirstReading: false });
37
43
  await tx.commitConfig();
38
- });
44
+ },
45
+ // Recording a reading never completes a torn row: the fence above
46
+ // refuses it, and the row's own next write completes it.
47
+ { completeTorn: false });
39
48
  }
40
49
  catch (error) {
41
50
  throw toFailure(error, 'pull', id, progress);
@@ -7,8 +7,11 @@ export type PoolOperation = 'initialize' | 'add' | 'replace' | 'rotate' | 'disab
7
7
  * `before-first-write`: nothing was written; both files are as they were.
8
8
  * `after-first-write`: the operation's first file write landed and a later one
9
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.
10
+ * (add: a state entry no roster row names, which no reader loads; replace:
11
+ * the new credential stamped ahead of the config, which readers show as a
12
+ * torn row and the next store write on it completes; rotate: the rotated
13
+ * credential beside the old per-row entry). `pull`: a quota pull, or the
14
+ * recording of its result, failed.
12
15
  */
13
16
  export type PoolFailurePhase = 'before-first-write' | 'after-first-write' | 'pull';
14
17
  /**
@@ -16,7 +19,7 @@ export type PoolFailurePhase = 'before-first-write' | 'after-first-write' | 'pul
16
19
  * lock outcomes (a wait that ran out, and a lease found lost); the rest are
17
20
  * refusals and failures of the operation itself.
18
21
  */
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-protected' | 'duplicate-identity' | 'row-key-changed' | 'invalid-order' | 'refresh-stamp-ahead' | 'attribution' | 'provider' | 'pull' | 'invalid-quota' | 'after-persist-hook' | 'unexpected';
22
+ 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-protected' | 'duplicate-identity' | 'identity-mismatch' | 'row-key-changed' | 'invalid-order' | 'refresh-stamp-ahead' | 'attribution' | 'provider' | 'pull' | 'invalid-quota' | 'after-persist-hook' | 'unexpected';
20
23
  /**
21
24
  * The single failure value of every store operation. `committed` is present
22
25
  * only when the operation had already written a credential to the state file
@@ -1,5 +1,14 @@
1
- import type { Transaction } from './mutate.js';
2
1
  import type { PoolRow } from './schema.js';
2
+ /**
3
+ * What the identity rules edit: a transaction, or a config being completed
4
+ * in memory.
5
+ */
6
+ export interface RowEditor {
7
+ rows(): PoolRow[];
8
+ rosterRow(id: string): Record<string, unknown> | undefined;
9
+ entry(id: string): Record<string, unknown> | undefined;
10
+ setEntry(id: string, entry: Record<string, unknown>): void;
11
+ }
3
12
  /** The reason recorded on a row disabled because an earlier row is the same account. */
4
13
  export declare const DUPLICATE_IDENTITY_REASON = "duplicate-identity";
5
14
  /**
@@ -12,12 +21,12 @@ export declare function countUnknownIdentityRows(rows: readonly PoolRow[]): numb
12
21
  * which older readers honour, and the reason in the per-row entry. A row
13
22
  * without an entry gets one at epoch 1. Nothing is ever deleted.
14
23
  */
15
- export declare function disableIn(tx: Transaction, id: string, reason: string): void;
24
+ export declare function disableIn(tx: RowEditor, id: string, reason: string): void;
16
25
  /**
17
26
  * Two enabled OAuth rows with one wire identity are the same account: the
18
27
  * earlier row in roster order stays enabled and every later one is disabled
19
28
  * with a reason. Returns the ids it disabled.
20
29
  */
21
- export declare function disableIdentityDuplicates(tx: Transaction, identity: string): string[];
30
+ export declare function disableIdentityDuplicates(tx: RowEditor, identity: string): string[];
22
31
  /** 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[];
32
+ export declare function recordIdentityIn(tx: RowEditor, id: string, identity: string): string[];
@@ -71,6 +71,11 @@ export declare class Transaction {
71
71
  operation: PoolOperation;
72
72
  rowId: string | undefined;
73
73
  });
74
+ /**
75
+ * The rows as every reader loads them: a row torn between the writes of a
76
+ * replace is shown as that replace leaves it once completed (see
77
+ * `PoolRow.torn`).
78
+ */
74
79
  rows(): PoolRow[];
75
80
  row(id: string): PoolRow | undefined;
76
81
  roster(): unknown[];
@@ -85,6 +90,14 @@ export declare class Transaction {
85
90
  entries(): Record<string, unknown>;
86
91
  entry(id: string): Record<string, unknown> | undefined;
87
92
  setEntry(id: string, entry: Record<string, unknown>): void;
93
+ /**
94
+ * Writes the config of every row torn between the writes of a replace as
95
+ * that replace would have left it (see `completeTornRows`), in one config
96
+ * write ahead of the operation's own. The write is counted apart from the
97
+ * operation's: it is setup, like a pull giving a row its entry, so a later
98
+ * refusal still reports `before-first-write`.
99
+ */
100
+ completeTorn(): Promise<void>;
88
101
  stateAccount(id: string): Record<string, unknown> | undefined;
89
102
  /** Drops the row's credential and runtime fields from the state file's accounts. */
90
103
  dropStateAccount(id: string): void;
@@ -95,7 +108,9 @@ export declare class Transaction {
95
108
  * key untouched. Entries for ids no longer in the roster are dropped here,
96
109
  * and remembered so the id is not reused in this process.
97
110
  */
98
- commitConfig(): Promise<void>;
111
+ commitConfig(options?: {
112
+ counted?: boolean;
113
+ }): Promise<void>;
99
114
  /** Writes the state: every unrecognised top-level and per-row key kept. */
100
115
  commitState(committed?: StoredCredential): Promise<void>;
101
116
  private write;
@@ -115,12 +130,17 @@ export type InitializeOutcome = 'initialized' | 'already-ready';
115
130
  export declare function initializePool(ctx: StoreContext, dropKeys: readonly string[]): Promise<InitializeOutcome>;
116
131
  /**
117
132
  * Runs `fn` under the store-lock list. The pool must be ready: a pending
118
- * migration or a load error refuses before anything is written.
133
+ * migration or a load error refuses before anything is written. Unless
134
+ * `completeTorn` is false, rows torn between the writes of a replace are
135
+ * completed first, so `fn` never sees one; the writes that only record
136
+ * readings or reorder the roster opt out and leave such rows as they are.
119
137
  */
120
138
  export declare function withTransaction<T>(ctx: StoreContext, locks: LockStack, progress: Progress, info: {
121
139
  operation: PoolOperation;
122
140
  rowId: string | undefined;
123
- }, fn: (tx: Transaction) => Promise<T>): Promise<T>;
141
+ }, fn: (tx: Transaction) => Promise<T>, options?: {
142
+ completeTorn?: boolean;
143
+ }): Promise<T>;
124
144
  /** Maps anything thrown inside an operation onto the one failure value. */
125
145
  export declare function toFailure(error: unknown, operation: PoolOperation, rowId: string | undefined, progress: Progress): PoolOperationError;
126
146
  /**
@@ -4,7 +4,8 @@ import { LockContentionError, LockOwnershipError } from '../fs/with-lock.js';
4
4
  import { PoolOperationError, } from './errors.js';
5
5
  import { callFailureHook } from './hooks.js';
6
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';
7
+ import { classifyConfig, classifyState, ensureEntries, entryIn, isRecord, LEGACY_STORE_VERSION, POOL_KEY, POOL_ROWS_KEY, POOL_SCHEMA_VERSION, rosterRowIn, setEntryIn, } from './schema.js';
8
+ import { completeTornRows, loadRows } from './torn.js';
8
9
  async function readJson(path) {
9
10
  let text;
10
11
  try {
@@ -38,7 +39,7 @@ export async function readPool(ctx) {
38
39
  stateExists: state.exists,
39
40
  config: config.config,
40
41
  state: state.state,
41
- rows: buildRows(config.config, state.state, ctx.codec),
42
+ rows: loadRows(config.config, state.state, ctx.codec),
42
43
  };
43
44
  }
44
45
  /** The refusal for a pool that is not ready, as a failure value. */
@@ -79,8 +80,13 @@ export class Transaction {
79
80
  this.config = structuredClone(snapshot.config);
80
81
  this.state = structuredClone(snapshot.state);
81
82
  }
83
+ /**
84
+ * The rows as every reader loads them: a row torn between the writes of a
85
+ * replace is shown as that replace leaves it once completed (see
86
+ * `PoolRow.torn`).
87
+ */
82
88
  rows() {
83
- return buildRows(this.config, this.state, this.ctx.codec);
89
+ return loadRows(this.config, this.state, this.ctx.codec);
84
90
  }
85
91
  row(id) {
86
92
  return this.rows().find((row) => row.id === id);
@@ -92,7 +98,8 @@ export class Transaction {
92
98
  }
93
99
  /** The first roster row with this id (the one the pool loads). */
94
100
  rosterRow(id) {
95
- return this.roster().find((raw) => isRecord(raw) && raw.id === id);
101
+ this.roster();
102
+ return rosterRowIn(this.config, id);
96
103
  }
97
104
  /**
98
105
  * Drops every roster row carrying this id. The row's per-row entry goes with
@@ -106,25 +113,28 @@ export class Transaction {
106
113
  return roster.length - kept.length;
107
114
  }
108
115
  entries() {
109
- if (!isRecord(this.config[POOL_KEY]))
110
- this.config[POOL_KEY] = {};
111
- const pool = this.config[POOL_KEY];
112
- if (!isRecord(pool[POOL_ROWS_KEY]))
113
- pool[POOL_ROWS_KEY] = {};
114
- return pool[POOL_ROWS_KEY];
116
+ return ensureEntries(this.config);
115
117
  }
116
118
  entry(id) {
117
- const entries = this.entries();
118
- const entry = Object.hasOwn(entries, id) ? entries[id] : undefined;
119
- return isRecord(entry) ? entry : undefined;
119
+ this.entries();
120
+ return entryIn(this.config, id);
120
121
  }
121
122
  setEntry(id, entry) {
122
- Object.defineProperty(this.entries(), id, {
123
- value: entry,
124
- enumerable: true,
125
- writable: true,
126
- configurable: true,
127
- });
123
+ setEntryIn(this.config, id, entry);
124
+ }
125
+ /**
126
+ * Writes the config of every row torn between the writes of a replace as
127
+ * that replace would have left it (see `completeTornRows`), in one config
128
+ * write ahead of the operation's own. The write is counted apart from the
129
+ * operation's: it is setup, like a pull giving a row its entry, so a later
130
+ * refusal still reports `before-first-write`.
131
+ */
132
+ async completeTorn() {
133
+ const { config, torn } = completeTornRows(this.config, this.state, this.ctx.codec);
134
+ if (torn.length === 0)
135
+ return;
136
+ this.config = config;
137
+ await this.commitConfig({ counted: false });
128
138
  }
129
139
  stateAccount(id) {
130
140
  const accounts = isRecord(this.state.accounts) ? this.state.accounts : {};
@@ -152,7 +162,7 @@ export class Transaction {
152
162
  * key untouched. Entries for ids no longer in the roster are dropped here,
153
163
  * and remembered so the id is not reused in this process.
154
164
  */
155
- async commitConfig() {
165
+ async commitConfig(options = {}) {
156
166
  const roster = this.roster();
157
167
  const rosterIds = new Set();
158
168
  for (const raw of roster)
@@ -184,7 +194,7 @@ export class Transaction {
184
194
  [POOL_ROWS_KEY]: kept,
185
195
  },
186
196
  };
187
- await this.write(this.ctx.configPath, next, 'config');
197
+ await this.write(this.ctx.configPath, next, 'config', options.counted ?? true);
188
198
  this.config = next;
189
199
  }
190
200
  /** Writes the state: every unrecognised top-level and per-row key kept. */
@@ -199,7 +209,7 @@ export class Transaction {
199
209
  if (committed)
200
210
  this.progress.committed = committed;
201
211
  }
202
- async write(path, value, file) {
212
+ async write(path, value, file, counted = true) {
203
213
  await writeJsonAtomic(path, value, {
204
214
  beforeRename: async () => {
205
215
  await this.ctx.onStep?.(`before-${file}-write`, this.info);
@@ -207,7 +217,8 @@ export class Transaction {
207
217
  await this.locks.assertAll();
208
218
  },
209
219
  });
210
- this.progress.writes++;
220
+ if (counted)
221
+ this.progress.writes++;
211
222
  await this.ctx.onStep?.(`after-${file}-write`, this.info);
212
223
  }
213
224
  }
@@ -258,9 +269,12 @@ export async function initializePool(ctx, dropKeys) {
258
269
  }
259
270
  /**
260
271
  * Runs `fn` under the store-lock list. The pool must be ready: a pending
261
- * migration or a load error refuses before anything is written.
272
+ * migration or a load error refuses before anything is written. Unless
273
+ * `completeTorn` is false, rows torn between the writes of a replace are
274
+ * completed first, so `fn` never sees one; the writes that only record
275
+ * readings or reorder the roster opt out and leave such rows as they are.
262
276
  */
263
- export async function withTransaction(ctx, locks, progress, info, fn) {
277
+ export async function withTransaction(ctx, locks, progress, info, fn, options = {}) {
264
278
  const mark = locks.held.length;
265
279
  try {
266
280
  for (const spec of ctx.storeLocks)
@@ -268,7 +282,10 @@ export async function withTransaction(ctx, locks, progress, info, fn) {
268
282
  const result = await readPool(ctx);
269
283
  if (result.status !== 'ready')
270
284
  throw notReadyError(result, info.operation, info.rowId, progress.writes > 0 ? 'after-first-write' : 'before-first-write');
271
- return await fn(new Transaction(ctx, result, locks, progress, info));
285
+ const tx = new Transaction(ctx, result, locks, progress, info);
286
+ if (options.completeTorn ?? true)
287
+ await tx.completeTorn();
288
+ return await fn(tx);
272
289
  }
273
290
  finally {
274
291
  await locks.releaseTo(mark);
@@ -120,7 +120,14 @@ export interface PoolStore {
120
120
  * (`invalid-input`). Takes `extraLocks`, then the store locks.
121
121
  */
122
122
  updateSettings(mutator: SettingsMutator, options?: UpdateSettingsOptions): Promise<UpdateSettingsResult>;
123
- recordIdentity(id: string, identity: string, options?: RowOperationOptions): Promise<{
123
+ /**
124
+ * Records the identity a lookup found for the row's credential. Since
125
+ * 0.3.1 it takes the credential epoch the lookup was issued for, and
126
+ * refuses a lookup that completes after the row was replaced
127
+ * (`attribution`) or a row recorded for another account
128
+ * (`identity-mismatch`).
129
+ */
130
+ recordIdentity(id: string, identity: string, attribution: Pick<Attribution, 'credentialEpoch'>, options?: RowOperationOptions): Promise<{
124
131
  id: string;
125
132
  disabled: string[];
126
133
  }>;
@@ -77,8 +77,13 @@ export function openPoolStore(options) {
77
77
  async load() {
78
78
  const result = await readPool(ctx);
79
79
  if (result.status === 'ready') {
80
+ // A torn row is no candidate, but its pull is fired too: the pull
81
+ // completes the row on disk before reading it, so an interrupted
82
+ // replace of an OAuth row heals at the next load.
80
83
  for (const row of result.rows)
81
- if (row.candidate && row.type === 'oauth' && row.needsFirstReading)
84
+ if ((row.candidate || (row.torn && row.enabled)) &&
85
+ row.type === 'oauth' &&
86
+ row.needsFirstReading)
82
87
  pulls.fire(row.id, 'load');
83
88
  }
84
89
  return toLoad(result);
@@ -98,7 +103,7 @@ export function openPoolStore(options) {
98
103
  reorder: (ids, callOptions) => reorderRows(rt, ids, callOptions),
99
104
  readSettings: () => readPoolSettings(rt),
100
105
  updateSettings: (mutator, callOptions) => updatePoolSettings(rt, mutator, callOptions),
101
- recordIdentity: (id, identity, callOptions) => recordRowIdentity(rt, id, identity, callOptions),
106
+ recordIdentity: (id, identity, attribution, callOptions) => recordRowIdentity(rt, id, identity, attribution, callOptions),
102
107
  refresh: (id, provider, callOptions) => refreshRow(rt, id, provider, callOptions),
103
108
  recordQuota: (id, attribution, observation) => recordQuota(rt, id, attribution, observation),
104
109
  requestReading: (id) => pulls.fire(id, 'admission'),
@@ -1,8 +1,9 @@
1
+ import type { Attribution } from './attribution.js';
1
2
  import { PoolOperationError } from './errors.js';
2
3
  import { type Transaction } from './mutate.js';
3
4
  import type { PoolLockSpec } from './refresh-lock.js';
4
5
  import { type StoreRuntime } from './runtime.js';
5
- import { type PoolCredential, type PoolRow, type StoredCredential } from './schema.js';
6
+ import { type CredentialBinding, type PoolCredential, type PoolRow, type StoredCredential } from './schema.js';
6
7
  export type FailureHook = (rowId: string, error: PoolOperationError) => void | Promise<void>;
7
8
  export interface RowOperationOptions {
8
9
  /** Called once, awaited, on every non-success path, before locks release. */
@@ -87,12 +88,15 @@ export type AddResult = {
87
88
  credential: StoredCredential;
88
89
  };
89
90
  /**
90
- * Writes a rotated credential into the state file (one write). A rotation is
91
- * the same lineage: no epoch bump, no identity or quota change.
91
+ * Writes a credential into the state file (one write), stamped with the
92
+ * credential epoch the row's entry holds in `tx` (1 without an entry) and,
93
+ * for a replace, the binding the config is about to get. A rotation is the
94
+ * same lineage: no epoch bump, no identity or quota change.
92
95
  */
93
96
  export declare function rotateIn(rt: StoreRuntime, tx: Transaction, id: string, credential: PoolCredential, extra?: {
94
97
  stamp?: number;
95
98
  clearErrors?: boolean;
99
+ binding?: CredentialBinding;
96
100
  }): Promise<StoredCredential>;
97
101
  export declare function addRow(rt: StoreRuntime, input: AddInput, options?: RowOperationOptions): Promise<AddResult>;
98
102
  export declare function replaceRow(rt: StoreRuntime, id: string, credential: PoolCredential, input?: {
@@ -146,7 +150,16 @@ export declare function removeRow(rt: StoreRuntime, id: string, options?: Remove
146
150
  * An order equal to the current one writes nothing.
147
151
  */
148
152
  export declare function reorderRows(rt: StoreRuntime, ids: readonly string[], options?: ReorderOptions): Promise<ReorderResult>;
149
- export declare function recordRowIdentity(rt: StoreRuntime, id: string, identity: string, options?: RowOperationOptions): Promise<{
153
+ /**
154
+ * Records the wire identity an identity lookup found for a row's credential.
155
+ * `attribution` is the credential epoch the lookup was issued for (a row
156
+ * without an entry is at epoch 1): a lookup that completes after the row was
157
+ * replaced is refused (`attribution`), as a quota reading would be, so the
158
+ * first credential's account is never recorded on the second credential. A
159
+ * row already recorded for another account refuses (`identity-mismatch`):
160
+ * that is a replacement, not something learnt about the same credential.
161
+ */
162
+ export declare function recordRowIdentity(rt: StoreRuntime, id: string, identity: string, attribution: Pick<Attribution, 'credentialEpoch'>, options?: RowOperationOptions): Promise<{
150
163
  id: string;
151
164
  disabled: string[];
152
165
  }>;