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 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
- | `dryRun` | `boolean` | `false` | With `prune`, log what *would* be dropped without dropping. |
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 pruning, log the indexes that *would* be dropped without dropping
91
- * them. Off by default.
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
- /** With `prune`, log candidates without dropping. */
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"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flongo",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "Firestore-like fluent query API for MongoDB",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",