flongo 2.0.0 → 2.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/README.md +84 -3
- package/dist/flongoCollection.d.ts +26 -0
- package/dist/flongoCollection.js +43 -0
- package/dist/flongoQuery.js +15 -12
- package/dist/indexes.d.ts +22 -8
- package/dist/indexes.js +110 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -168,6 +168,32 @@ const topRated = await products.aggregate([
|
|
|
168
168
|
]);
|
|
169
169
|
```
|
|
170
170
|
|
|
171
|
+
#### Id projection & semi-joins (`getIds`)
|
|
172
|
+
|
|
173
|
+
When you only need to know **which** documents match — not their contents —
|
|
174
|
+
`getIds(query, pagination?)` returns matching `_id`s as strings. It uses a
|
|
175
|
+
server-side projection, so full documents never leave the server. This is the
|
|
176
|
+
natural fit for a semi-join: "of these N candidate ids, which pass this filter?"
|
|
177
|
+
|
|
178
|
+
```typescript
|
|
179
|
+
// Of these candidate stayIds, which are publicly visible?
|
|
180
|
+
const visibleIds = await stays.getIds(
|
|
181
|
+
new FlongoQuery()
|
|
182
|
+
.where('_id').in(candidateStayIds)
|
|
183
|
+
.and('status').eq('Accepted')
|
|
184
|
+
.and('enable').eq(true)
|
|
185
|
+
);
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
`getIds` honors the same query and pagination semantics as `getAll`, including
|
|
189
|
+
sort clauses — ordered ids are useful for id-first pagination.
|
|
190
|
+
|
|
191
|
+
> **Note (2.2.0):** inline `_id` clauses now compose with other field clauses,
|
|
192
|
+
> as the example above relies on. Previously an `_id` clause caused every other
|
|
193
|
+
> clause in the same clause list to be silently dropped, and filters had to be
|
|
194
|
+
> attached via `.andQuery(new FlongoQuery().where('_id').in(ids))` — that
|
|
195
|
+
> pattern still works unchanged.
|
|
196
|
+
|
|
171
197
|
### Collection Operations
|
|
172
198
|
|
|
173
199
|
```typescript
|
|
@@ -184,6 +210,7 @@ const user = await collection.create({
|
|
|
184
210
|
// Read
|
|
185
211
|
const user = await collection.get('user123');
|
|
186
212
|
const users = await collection.getAll(query);
|
|
213
|
+
const ids = await collection.getIds(query); // matching _ids only (server-side projection)
|
|
187
214
|
const first = await collection.getFirst(query);
|
|
188
215
|
const count = await collection.count(query);
|
|
189
216
|
const exists = await collection.exists(query);
|
|
@@ -307,7 +334,7 @@ structured report:
|
|
|
307
334
|
interface FlongoIndexReport {
|
|
308
335
|
collection: string;
|
|
309
336
|
name: string; // resolved name
|
|
310
|
-
status: 'created' | 'exists' | 'conflict' | 'failed' | 'pruned';
|
|
337
|
+
status: 'created' | 'exists' | 'conflict' | 'failed' | 'pruned' | 'reconciled';
|
|
311
338
|
error?: string;
|
|
312
339
|
}
|
|
313
340
|
```
|
|
@@ -315,7 +342,8 @@ interface FlongoIndexReport {
|
|
|
315
342
|
- **Identical** spec already present → `"exists"` (no-op).
|
|
316
343
|
- **Same keys, different options** → MongoDB throws `IndexOptionsConflict`;
|
|
317
344
|
Flongo records `"conflict"` and honors `onError` (it never silently
|
|
318
|
-
drops/recreates).
|
|
345
|
+
drops/recreates). With the `reconcile` opt-in, the existing index is instead
|
|
346
|
+
dropped and rebuilt from the declared spec → `"reconciled"` (see below).
|
|
319
347
|
- **Creation error** (classic case: a `unique` index over a collection that
|
|
320
348
|
already contains duplicates) → `"failed"` with a message naming the likely
|
|
321
349
|
cause. By default this does **not** crash the process.
|
|
@@ -328,7 +356,8 @@ interface FlongoIndexReport {
|
|
|
328
356
|
| `onError` | `'warn'` \| `'throw'` | `'warn'` | How non-fatal problems are surfaced. `strict` mode always throws. |
|
|
329
357
|
| `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
358
|
| `prune` | `boolean` | `false` | Drop indexes present in Mongo but absent from the registry. See below. |
|
|
331
|
-
| `
|
|
359
|
+
| `reconcile` | `boolean` | `false` | Drop + rebuild conflicting indexes from their declared specs. See below. |
|
|
360
|
+
| `dryRun` | `boolean` | `false` | With `prune`/`reconcile`, log what *would* be dropped (or dropped and rebuilt) without touching anything. |
|
|
332
361
|
|
|
333
362
|
### Non-destructive by default & pruning
|
|
334
363
|
|
|
@@ -344,6 +373,39 @@ await syncFlongoIndexes({ prune: true, dryRun: true });
|
|
|
344
373
|
const report = await syncFlongoIndexes({ prune: true });
|
|
345
374
|
```
|
|
346
375
|
|
|
376
|
+
### Reconciling option changes
|
|
377
|
+
|
|
378
|
+
Changing a declared index's **options** (e.g. adding `unique`, changing a
|
|
379
|
+
partial filter) makes MongoDB reject the `createIndex` with a conflict, since
|
|
380
|
+
an index's options are immutable. By default Flongo reports `"conflict"` and
|
|
381
|
+
leaves the resolution to you. With the `reconcile` opt-in, Flongo evolves the
|
|
382
|
+
index for you: it drops the existing index and rebuilds it from the declared
|
|
383
|
+
spec.
|
|
384
|
+
|
|
385
|
+
```typescript
|
|
386
|
+
// Dry run first — logs which indexes would be dropped and rebuilt
|
|
387
|
+
await syncFlongoIndexes({ reconcile: true, dryRun: true });
|
|
388
|
+
|
|
389
|
+
// Then actually reconcile
|
|
390
|
+
const report = await syncFlongoIndexes({ reconcile: true });
|
|
391
|
+
// → [{ collection: 'users', name: 'email_1', status: 'reconciled' }]
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
Safety properties:
|
|
395
|
+
|
|
396
|
+
- **Rollback on failure** — if the rebuild fails (e.g. you added `unique` and
|
|
397
|
+
the collection contains duplicates), the original index is restored from the
|
|
398
|
+
pre-sync snapshot and the report is `"failed"` with the cause. The collection
|
|
399
|
+
is never left without the index.
|
|
400
|
+
- **Drops the real index** — the drop targets the server-side index that
|
|
401
|
+
conflicts (matched by name, or by key pattern if your spec renames it), never
|
|
402
|
+
a guessed name, and never `_id_`.
|
|
403
|
+
|
|
404
|
+
⚠️ **Rebuild window**: between the drop and the completed rebuild, queries
|
|
405
|
+
can't use the index and `unique` is not enforced. For large collections or
|
|
406
|
+
strict uniqueness requirements, prefer running reconcile during a maintenance
|
|
407
|
+
window rather than on every boot.
|
|
408
|
+
|
|
347
409
|
### Verifying indexes
|
|
348
410
|
|
|
349
411
|
Two passthroughs help confirm indexes are applied and used:
|
|
@@ -467,6 +529,25 @@ const users = await collection.getAll(
|
|
|
467
529
|
);
|
|
468
530
|
```
|
|
469
531
|
|
|
532
|
+
## Changelog
|
|
533
|
+
|
|
534
|
+
### 2.2.0
|
|
535
|
+
|
|
536
|
+
- **`FlongoCollection.getIds(query?, pagination?)`** — returns matching `_id`s
|
|
537
|
+
as strings via a server-side projection (`{_id: 1}` on `find`, `$project` on
|
|
538
|
+
the aggregation path), so full documents never cross the wire. Honors the
|
|
539
|
+
same query, sort, and pagination semantics as `getAll`.
|
|
540
|
+
- **Inline `_id` clauses now compose in `build()`.** Previously an `_id`
|
|
541
|
+
expression in the main clause list short-circuited the build and silently
|
|
542
|
+
discarded every other clause; combining `_id` with other filters required the
|
|
543
|
+
`.andQuery(new FlongoQuery().where('_id').in(ids))` workaround. Now `_id`
|
|
544
|
+
composes like any other field (string → ObjectId conversion is preserved for
|
|
545
|
+
both single values and `$in` arrays). Queries with **only** an `_id` clause
|
|
546
|
+
build identical output to before, and the `andQuery` pattern still works.
|
|
547
|
+
*Behavior note:* if a query combined `_id` with other clauses **and** relied
|
|
548
|
+
on those other clauses being ignored, it now returns only documents matching
|
|
549
|
+
all clauses — i.e., what the query literally says.
|
|
550
|
+
|
|
470
551
|
## License
|
|
471
552
|
|
|
472
553
|
MIT
|
|
@@ -98,6 +98,32 @@ export declare class FlongoCollection<T> {
|
|
|
98
98
|
* @private
|
|
99
99
|
*/
|
|
100
100
|
private getAllRandom;
|
|
101
|
+
/**
|
|
102
|
+
* Retrieves only the `_id`s of matching documents, as strings (the same
|
|
103
|
+
* normalization `toEntity` applies). Uses a server-side projection so full
|
|
104
|
+
* documents never leave the server — ideal for semi-joins ("which of these N
|
|
105
|
+
* ids match this filter?") and other membership checks where hydrating whole
|
|
106
|
+
* documents is wasted work.
|
|
107
|
+
*
|
|
108
|
+
* Honors the same query and pagination semantics as `getAll`, including sort
|
|
109
|
+
* clauses (ordered ids are useful for id-first pagination).
|
|
110
|
+
*
|
|
111
|
+
* Example usage:
|
|
112
|
+
* ```typescript
|
|
113
|
+
* // Semi-join: of these candidate stayIds, which are publicly visible?
|
|
114
|
+
* const visibleIds = await stays.getIds(
|
|
115
|
+
* new FlongoQuery()
|
|
116
|
+
* .where('_id').in(candidateStayIds)
|
|
117
|
+
* .and('status').eq('Accepted')
|
|
118
|
+
* .and('enable').eq(true)
|
|
119
|
+
* );
|
|
120
|
+
* ```
|
|
121
|
+
*
|
|
122
|
+
* @param query - Optional FlongoQuery for filtering
|
|
123
|
+
* @param pagination - Optional pagination settings
|
|
124
|
+
* @returns Promise resolving to the matching document ids as strings
|
|
125
|
+
*/
|
|
126
|
+
getIds(query?: FlongoQuery, pagination?: Pagination): Promise<string[]>;
|
|
101
127
|
/**
|
|
102
128
|
* Runs a raw aggregation pipeline against this collection and returns the
|
|
103
129
|
* documents with their `_id` normalized to a string (Entity form). A low-level
|
package/dist/flongoCollection.js
CHANGED
|
@@ -155,6 +155,49 @@ class FlongoCollection {
|
|
|
155
155
|
const res = await this.collection.aggregate(pipeline).toArray();
|
|
156
156
|
return res.map((d) => this.toEntity(d));
|
|
157
157
|
}
|
|
158
|
+
/**
|
|
159
|
+
* Retrieves only the `_id`s of matching documents, as strings (the same
|
|
160
|
+
* normalization `toEntity` applies). Uses a server-side projection so full
|
|
161
|
+
* documents never leave the server — ideal for semi-joins ("which of these N
|
|
162
|
+
* ids match this filter?") and other membership checks where hydrating whole
|
|
163
|
+
* documents is wasted work.
|
|
164
|
+
*
|
|
165
|
+
* Honors the same query and pagination semantics as `getAll`, including sort
|
|
166
|
+
* clauses (ordered ids are useful for id-first pagination).
|
|
167
|
+
*
|
|
168
|
+
* Example usage:
|
|
169
|
+
* ```typescript
|
|
170
|
+
* // Semi-join: of these candidate stayIds, which are publicly visible?
|
|
171
|
+
* const visibleIds = await stays.getIds(
|
|
172
|
+
* new FlongoQuery()
|
|
173
|
+
* .where('_id').in(candidateStayIds)
|
|
174
|
+
* .and('status').eq('Accepted')
|
|
175
|
+
* .and('enable').eq(true)
|
|
176
|
+
* );
|
|
177
|
+
* ```
|
|
178
|
+
*
|
|
179
|
+
* @param query - Optional FlongoQuery for filtering
|
|
180
|
+
* @param pagination - Optional pagination settings
|
|
181
|
+
* @returns Promise resolving to the matching document ids as strings
|
|
182
|
+
*/
|
|
183
|
+
async getIds(query, pagination) {
|
|
184
|
+
// Mirror getAll: a seeded random sort must execute via the aggregation
|
|
185
|
+
// pipeline. Only the id column is materialized on either path.
|
|
186
|
+
if (query?.hasRandomSort()) {
|
|
187
|
+
await this.assertRandomSortSupported();
|
|
188
|
+
const pipeline = query.buildPipeline(pagination);
|
|
189
|
+
pipeline.push({ $project: { _id: 1 } });
|
|
190
|
+
const res = await this.collection.aggregate(pipeline).toArray();
|
|
191
|
+
return res.map((d) => String(d._id));
|
|
192
|
+
}
|
|
193
|
+
const mongodbQuery = query?.build() ?? {};
|
|
194
|
+
const mongodbOptions = {
|
|
195
|
+
...(query?.buildOptions(pagination) ?? new flongoQuery_1.FlongoQuery().buildOptions(pagination)),
|
|
196
|
+
projection: { _id: 1 }
|
|
197
|
+
};
|
|
198
|
+
const res = await this.collection.find(mongodbQuery, mongodbOptions).toArray();
|
|
199
|
+
return res.map((d) => String(d._id));
|
|
200
|
+
}
|
|
158
201
|
/**
|
|
159
202
|
* Runs a raw aggregation pipeline against this collection and returns the
|
|
160
203
|
* documents with their `_id` normalized to a string (Entity form). A low-level
|
package/dist/flongoQuery.js
CHANGED
|
@@ -492,25 +492,24 @@ class FlongoQuery {
|
|
|
492
492
|
// Process main expressions
|
|
493
493
|
if (this.expressions) {
|
|
494
494
|
for (const expression of this.expressions) {
|
|
495
|
-
//
|
|
495
|
+
// Build query object for this clause
|
|
496
|
+
let fieldValue;
|
|
496
497
|
if (expression.key === "_id") {
|
|
498
|
+
// _id keeps its string -> ObjectId conversion but composes with the
|
|
499
|
+
// other clauses like any field (it used to short-circuit the build,
|
|
500
|
+
// silently discarding every co-existing clause).
|
|
497
501
|
if (Array.isArray(expression.val)) {
|
|
498
502
|
// Multiple IDs: {_id: {$in: [ObjectId(...), ObjectId(...)]}}
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
[expression.op ?? "$in"]: expression.val.map((element) => new mongodb_1.ObjectId(element))
|
|
502
|
-
}
|
|
503
|
+
fieldValue = {
|
|
504
|
+
[expression.op ?? "$in"]: expression.val.map((element) => new mongodb_1.ObjectId(element))
|
|
503
505
|
};
|
|
504
506
|
}
|
|
505
507
|
else {
|
|
506
508
|
// Single ID: {_id: ObjectId(...)}
|
|
507
|
-
|
|
509
|
+
fieldValue = new mongodb_1.ObjectId(expression.val);
|
|
508
510
|
}
|
|
509
|
-
break; // _id queries are typically exclusive
|
|
510
511
|
}
|
|
511
|
-
|
|
512
|
-
let fieldValue;
|
|
513
|
-
if (!expression.op) {
|
|
512
|
+
else if (!expression.op) {
|
|
514
513
|
// Direct value assignment for simple equality (arrContains)
|
|
515
514
|
fieldValue = expression.val;
|
|
516
515
|
}
|
|
@@ -528,10 +527,14 @@ class FlongoQuery {
|
|
|
528
527
|
// Standard operator
|
|
529
528
|
fieldValue = { [expression.op]: expression.val };
|
|
530
529
|
}
|
|
531
|
-
// Merge operators for the same field (e.g., range queries)
|
|
530
|
+
// Merge operators for the same field (e.g., range queries).
|
|
531
|
+
// ObjectId instances are direct values, not operator maps — spreading
|
|
532
|
+
// one would destroy it, so they always assign.
|
|
532
533
|
if (mongodbQuery[expression.key] &&
|
|
533
534
|
typeof mongodbQuery[expression.key] === "object" &&
|
|
534
|
-
|
|
535
|
+
!(mongodbQuery[expression.key] instanceof mongodb_1.ObjectId) &&
|
|
536
|
+
typeof fieldValue === "object" &&
|
|
537
|
+
!(fieldValue instanceof mongodb_1.ObjectId)) {
|
|
535
538
|
mongodbQuery[expression.key] = { ...mongodbQuery[expression.key], ...fieldValue };
|
|
536
539
|
}
|
|
537
540
|
else {
|
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"
|