flongo 2.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -3
- package/dist/indexes.d.ts +22 -8
- package/dist/indexes.js +110 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -307,7 +307,7 @@ structured report:
|
|
|
307
307
|
interface FlongoIndexReport {
|
|
308
308
|
collection: string;
|
|
309
309
|
name: string; // resolved name
|
|
310
|
-
status: 'created' | 'exists' | 'conflict' | 'failed' | 'pruned';
|
|
310
|
+
status: 'created' | 'exists' | 'conflict' | 'failed' | 'pruned' | 'reconciled';
|
|
311
311
|
error?: string;
|
|
312
312
|
}
|
|
313
313
|
```
|
|
@@ -315,7 +315,8 @@ interface FlongoIndexReport {
|
|
|
315
315
|
- **Identical** spec already present → `"exists"` (no-op).
|
|
316
316
|
- **Same keys, different options** → MongoDB throws `IndexOptionsConflict`;
|
|
317
317
|
Flongo records `"conflict"` and honors `onError` (it never silently
|
|
318
|
-
drops/recreates).
|
|
318
|
+
drops/recreates). With the `reconcile` opt-in, the existing index is instead
|
|
319
|
+
dropped and rebuilt from the declared spec → `"reconciled"` (see below).
|
|
319
320
|
- **Creation error** (classic case: a `unique` index over a collection that
|
|
320
321
|
already contains duplicates) → `"failed"` with a message naming the likely
|
|
321
322
|
cause. By default this does **not** crash the process.
|
|
@@ -328,7 +329,8 @@ interface FlongoIndexReport {
|
|
|
328
329
|
| `onError` | `'warn'` \| `'throw'` | `'warn'` | How non-fatal problems are surfaced. `strict` mode always throws. |
|
|
329
330
|
| `background` | `boolean` | `false` | When `true`, boot does not block on index builds — the sync runs asynchronously and logs its outcome. `await syncFlongoIndexes()` remains available when you want to await. |
|
|
330
331
|
| `prune` | `boolean` | `false` | Drop indexes present in Mongo but absent from the registry. See below. |
|
|
331
|
-
| `
|
|
332
|
+
| `reconcile` | `boolean` | `false` | Drop + rebuild conflicting indexes from their declared specs. See below. |
|
|
333
|
+
| `dryRun` | `boolean` | `false` | With `prune`/`reconcile`, log what *would* be dropped (or dropped and rebuilt) without touching anything. |
|
|
332
334
|
|
|
333
335
|
### Non-destructive by default & pruning
|
|
334
336
|
|
|
@@ -344,6 +346,39 @@ await syncFlongoIndexes({ prune: true, dryRun: true });
|
|
|
344
346
|
const report = await syncFlongoIndexes({ prune: true });
|
|
345
347
|
```
|
|
346
348
|
|
|
349
|
+
### Reconciling option changes
|
|
350
|
+
|
|
351
|
+
Changing a declared index's **options** (e.g. adding `unique`, changing a
|
|
352
|
+
partial filter) makes MongoDB reject the `createIndex` with a conflict, since
|
|
353
|
+
an index's options are immutable. By default Flongo reports `"conflict"` and
|
|
354
|
+
leaves the resolution to you. With the `reconcile` opt-in, Flongo evolves the
|
|
355
|
+
index for you: it drops the existing index and rebuilds it from the declared
|
|
356
|
+
spec.
|
|
357
|
+
|
|
358
|
+
```typescript
|
|
359
|
+
// Dry run first — logs which indexes would be dropped and rebuilt
|
|
360
|
+
await syncFlongoIndexes({ reconcile: true, dryRun: true });
|
|
361
|
+
|
|
362
|
+
// Then actually reconcile
|
|
363
|
+
const report = await syncFlongoIndexes({ reconcile: true });
|
|
364
|
+
// → [{ collection: 'users', name: 'email_1', status: 'reconciled' }]
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
Safety properties:
|
|
368
|
+
|
|
369
|
+
- **Rollback on failure** — if the rebuild fails (e.g. you added `unique` and
|
|
370
|
+
the collection contains duplicates), the original index is restored from the
|
|
371
|
+
pre-sync snapshot and the report is `"failed"` with the cause. The collection
|
|
372
|
+
is never left without the index.
|
|
373
|
+
- **Drops the real index** — the drop targets the server-side index that
|
|
374
|
+
conflicts (matched by name, or by key pattern if your spec renames it), never
|
|
375
|
+
a guessed name, and never `_id_`.
|
|
376
|
+
|
|
377
|
+
⚠️ **Rebuild window**: between the drop and the completed rebuild, queries
|
|
378
|
+
can't use the index and `unique` is not enforced. For large collections or
|
|
379
|
+
strict uniqueness requirements, prefer running reconcile during a maintenance
|
|
380
|
+
window rather than on every boot.
|
|
381
|
+
|
|
347
382
|
### Verifying indexes
|
|
348
383
|
|
|
349
384
|
Two passthroughs help confirm indexes are applied and used:
|
package/dist/indexes.d.ts
CHANGED
|
@@ -87,13 +87,23 @@ export interface IndexSyncOptions {
|
|
|
87
87
|
*/
|
|
88
88
|
prune?: boolean;
|
|
89
89
|
/**
|
|
90
|
-
* When
|
|
91
|
-
*
|
|
90
|
+
* When `true`, resolve index conflicts (an index with the same identity
|
|
91
|
+
* already exists with different options or keys) by dropping the existing
|
|
92
|
+
* index and rebuilding it from the declared spec. If the rebuild fails, the
|
|
93
|
+
* original index is restored from the pre-sync snapshot so the collection is
|
|
94
|
+
* never left without it. Note the rebuild window: while the index is being
|
|
95
|
+
* rebuilt, queries can't use it and `unique` is not enforced. Opt-in and off
|
|
96
|
+
* by default; never touches `_id_`. See also `dryRun`.
|
|
97
|
+
*/
|
|
98
|
+
reconcile?: boolean;
|
|
99
|
+
/**
|
|
100
|
+
* When pruning or reconciling, log the indexes that *would* be dropped (or
|
|
101
|
+
* dropped and rebuilt) without touching them. Off by default.
|
|
92
102
|
*/
|
|
93
103
|
dryRun?: boolean;
|
|
94
104
|
}
|
|
95
|
-
/** The outcome of ensuring (or pruning) a single index. */
|
|
96
|
-
export type FlongoIndexStatus = "created" | "exists" | "conflict" | "failed" | "pruned";
|
|
105
|
+
/** The outcome of ensuring (or pruning/reconciling) a single index. */
|
|
106
|
+
export type FlongoIndexStatus = "created" | "exists" | "conflict" | "failed" | "pruned" | "reconciled";
|
|
97
107
|
/** A structured, per-index result from {@link syncFlongoIndexes}. */
|
|
98
108
|
export interface FlongoIndexReport {
|
|
99
109
|
/** Collection the index belongs to. */
|
|
@@ -113,7 +123,9 @@ export interface SyncFlongoIndexesOptions {
|
|
|
113
123
|
onError?: IndexSyncOnError;
|
|
114
124
|
/** Drop out-of-registry indexes (never `_id_`). */
|
|
115
125
|
prune?: boolean;
|
|
116
|
-
/**
|
|
126
|
+
/** Drop + rebuild conflicting indexes from their declared specs (never `_id_`). */
|
|
127
|
+
reconcile?: boolean;
|
|
128
|
+
/** With `prune`/`reconcile`, log candidates without dropping. */
|
|
117
129
|
dryRun?: boolean;
|
|
118
130
|
}
|
|
119
131
|
/**
|
|
@@ -141,16 +153,18 @@ export declare function defaultIndexName(keys: Record<string, FlongoIndexKeyType
|
|
|
141
153
|
* For each spec, calls `createIndex(keys, options)`:
|
|
142
154
|
* - identical spec already present → `"exists"` (no-op);
|
|
143
155
|
* - same keys with different options → Mongo throws a conflict, recorded as
|
|
144
|
-
* `"conflict"` (never silently dropped/recreated)
|
|
156
|
+
* `"conflict"` (never silently dropped/recreated) — unless `reconcile` is on,
|
|
157
|
+
* in which case the existing index is dropped and rebuilt from the spec
|
|
158
|
+
* (`"reconciled"`), with the original restored if the rebuild fails;
|
|
145
159
|
* - other creation errors (e.g. a unique index over existing duplicates) →
|
|
146
160
|
* `"failed"` with a diagnostic message.
|
|
147
161
|
*
|
|
148
162
|
* `mode`/`onError` are read from the `indexSync` config unless overridden here.
|
|
149
163
|
* `strict` mode forces `onError: "throw"`. With `prune`, indexes present in
|
|
150
164
|
* Mongo but absent from the registry are dropped (never `_id_`); `dryRun` logs
|
|
151
|
-
* candidates without dropping.
|
|
165
|
+
* prune/reconcile candidates without dropping.
|
|
152
166
|
*
|
|
153
|
-
* @param opts - Per-call overrides for mode, onError, prune, and dryRun.
|
|
167
|
+
* @param opts - Per-call overrides for mode, onError, prune, reconcile, and dryRun.
|
|
154
168
|
* @returns One {@link FlongoIndexReport} per ensured (and pruned) index.
|
|
155
169
|
*/
|
|
156
170
|
export declare function syncFlongoIndexes(opts?: SyncFlongoIndexesOptions): Promise<FlongoIndexReport[]>;
|
package/dist/indexes.js
CHANGED
|
@@ -92,6 +92,99 @@ function describeFailure(err) {
|
|
|
92
92
|
}
|
|
93
93
|
return base;
|
|
94
94
|
}
|
|
95
|
+
/** Order-sensitive equality between an existing index's key doc and a spec's keys. */
|
|
96
|
+
function sameKeyPattern(existingKey, specKeys) {
|
|
97
|
+
const a = Object.entries(existingKey ?? {});
|
|
98
|
+
const b = Object.entries(specKeys);
|
|
99
|
+
return a.length === b.length && a.every(([field, type], i) => b[i][0] === field && b[i][1] === type);
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Finds the existing index a conflicting spec collides with: same resolved
|
|
103
|
+
* name, or (for an explicitly renamed spec) the same key pattern under another
|
|
104
|
+
* name. The server-side index is the identity that must be dropped to
|
|
105
|
+
* reconcile — not the spec's resolved name, which may not exist yet.
|
|
106
|
+
*/
|
|
107
|
+
function findConflictingIndex(existing, spec) {
|
|
108
|
+
const name = resolveIndexName(spec);
|
|
109
|
+
return (existing.find((i) => i.name === name) ??
|
|
110
|
+
existing.find((i) => sameKeyPattern(i.key, spec.keys)));
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Rebuilds `createIndex` options from a `listIndexes` document so a dropped
|
|
114
|
+
* index can be restored verbatim, minus the server-managed fields that
|
|
115
|
+
* `createIndex` does not accept back.
|
|
116
|
+
*/
|
|
117
|
+
function restoreOptionsFromIndexDoc(doc) {
|
|
118
|
+
const options = {};
|
|
119
|
+
for (const [field, value] of Object.entries(doc)) {
|
|
120
|
+
if (field !== "v" && field !== "key" && field !== "ns" && field !== "background") {
|
|
121
|
+
options[field] = value;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return options;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Attempts to reconcile a conflicting spec by dropping the existing index and
|
|
128
|
+
* rebuilding it from the declaration. If the rebuild fails, the original index
|
|
129
|
+
* is restored from the pre-sync snapshot so the collection is never left
|
|
130
|
+
* without it. Returns the report for the attempt, or `undefined` when there is
|
|
131
|
+
* no safely identifiable drop target (or the target is `_id_`, or this is a
|
|
132
|
+
* dry run), in which case the caller falls back to plain conflict handling.
|
|
133
|
+
*/
|
|
134
|
+
async function reconcileIndex(collection, collectionName, spec, existing, dryRun) {
|
|
135
|
+
const target = findConflictingIndex(existing, spec);
|
|
136
|
+
if (!target || target.name === "_id_") {
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
const targetName = target.name;
|
|
140
|
+
const resolvedName = resolveIndexName(spec);
|
|
141
|
+
if (dryRun) {
|
|
142
|
+
console.warn(`[flongo] [dry-run] would reconcile index ${collectionName}.${targetName} (drop + rebuild as ${resolvedName})`);
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
await collection.dropIndex(targetName);
|
|
147
|
+
}
|
|
148
|
+
catch (err) {
|
|
149
|
+
return {
|
|
150
|
+
collection: collectionName,
|
|
151
|
+
name: targetName,
|
|
152
|
+
status: "failed",
|
|
153
|
+
error: `reconcile could not drop existing index: ${err?.message ?? String(err)}`
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
const createdName = await collection.createIndex(spec.keys, (spec.options ?? {}));
|
|
158
|
+
console.warn(`[flongo] Reconciled index ${collectionName}.${createdName ?? resolvedName} (dropped ${targetName}, rebuilt with declared options)`);
|
|
159
|
+
return {
|
|
160
|
+
collection: collectionName,
|
|
161
|
+
name: createdName ?? resolvedName,
|
|
162
|
+
status: "reconciled"
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
catch (err) {
|
|
166
|
+
const rebuildFailure = describeFailure(err);
|
|
167
|
+
// The old index is already gone; restore it rather than leaving the
|
|
168
|
+
// collection with no index at all.
|
|
169
|
+
try {
|
|
170
|
+
await collection.createIndex(target.key, restoreOptionsFromIndexDoc(target));
|
|
171
|
+
return {
|
|
172
|
+
collection: collectionName,
|
|
173
|
+
name: resolvedName,
|
|
174
|
+
status: "failed",
|
|
175
|
+
error: `reconcile rebuild failed; original index ${targetName} was restored — ${rebuildFailure}`
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
catch (restoreErr) {
|
|
179
|
+
return {
|
|
180
|
+
collection: collectionName,
|
|
181
|
+
name: resolvedName,
|
|
182
|
+
status: "failed",
|
|
183
|
+
error: `reconcile rebuild failed and restoring ${targetName} also failed — the collection is currently missing this index. Rebuild: ${rebuildFailure}; restore: ${restoreErr?.message ?? String(restoreErr)}`
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
95
188
|
/** Logs a one-line summary of a sync run, grouped by status. */
|
|
96
189
|
function logSummary(reports) {
|
|
97
190
|
const counts = {};
|
|
@@ -113,22 +206,25 @@ function logSummary(reports) {
|
|
|
113
206
|
* For each spec, calls `createIndex(keys, options)`:
|
|
114
207
|
* - identical spec already present → `"exists"` (no-op);
|
|
115
208
|
* - same keys with different options → Mongo throws a conflict, recorded as
|
|
116
|
-
* `"conflict"` (never silently dropped/recreated)
|
|
209
|
+
* `"conflict"` (never silently dropped/recreated) — unless `reconcile` is on,
|
|
210
|
+
* in which case the existing index is dropped and rebuilt from the spec
|
|
211
|
+
* (`"reconciled"`), with the original restored if the rebuild fails;
|
|
117
212
|
* - other creation errors (e.g. a unique index over existing duplicates) →
|
|
118
213
|
* `"failed"` with a diagnostic message.
|
|
119
214
|
*
|
|
120
215
|
* `mode`/`onError` are read from the `indexSync` config unless overridden here.
|
|
121
216
|
* `strict` mode forces `onError: "throw"`. With `prune`, indexes present in
|
|
122
217
|
* Mongo but absent from the registry are dropped (never `_id_`); `dryRun` logs
|
|
123
|
-
* candidates without dropping.
|
|
218
|
+
* prune/reconcile candidates without dropping.
|
|
124
219
|
*
|
|
125
|
-
* @param opts - Per-call overrides for mode, onError, prune, and dryRun.
|
|
220
|
+
* @param opts - Per-call overrides for mode, onError, prune, reconcile, and dryRun.
|
|
126
221
|
* @returns One {@link FlongoIndexReport} per ensured (and pruned) index.
|
|
127
222
|
*/
|
|
128
223
|
async function syncFlongoIndexes(opts = {}) {
|
|
129
224
|
const cfg = getIndexSyncConfig();
|
|
130
225
|
const mode = opts.mode ?? cfg.mode ?? "ensure";
|
|
131
226
|
const prune = opts.prune ?? cfg.prune ?? false;
|
|
227
|
+
const reconcile = opts.reconcile ?? cfg.reconcile ?? false;
|
|
132
228
|
const dryRun = opts.dryRun ?? cfg.dryRun ?? false;
|
|
133
229
|
// Strict mode always throws; otherwise honor the configured/overridden policy.
|
|
134
230
|
const onError = mode === "strict" ? "throw" : opts.onError ?? cfg.onError ?? "warn";
|
|
@@ -160,6 +256,17 @@ async function syncFlongoIndexes(opts = {}) {
|
|
|
160
256
|
}
|
|
161
257
|
catch (err) {
|
|
162
258
|
const conflict = isConflictError(err);
|
|
259
|
+
if (conflict && reconcile) {
|
|
260
|
+
const report = await reconcileIndex(collection, collectionName, spec, existing, dryRun);
|
|
261
|
+
if (report) {
|
|
262
|
+
reports.push(report);
|
|
263
|
+
if (report.status === "failed") {
|
|
264
|
+
raise("failed", collectionName, report.name, report.error ?? "reconcile failed");
|
|
265
|
+
}
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
// No safe drop target (or dry run): fall through to plain conflict handling.
|
|
269
|
+
}
|
|
163
270
|
const status = conflict ? "conflict" : "failed";
|
|
164
271
|
const message = conflict
|
|
165
272
|
? err?.message ?? "index options conflict"
|