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 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
- | `dryRun` | `boolean` | `false` | With `prune`, log what *would* be dropped without dropping. |
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
@@ -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
@@ -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
- // Special handling for _id field - convert strings to ObjectIds
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
- mongodbQuery = {
500
- _id: {
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
- mongodbQuery = { _id: new mongodb_1.ObjectId(expression.val) };
509
+ fieldValue = new mongodb_1.ObjectId(expression.val);
508
510
  }
509
- break; // _id queries are typically exclusive
510
511
  }
511
- // Build query object for non-_id fields
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
- typeof fieldValue === "object") {
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 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.2.0",
4
4
  "description": "Firestore-like fluent query API for MongoDB",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",