@ignex/ninox 0.1.5 → 0.1.6

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
@@ -787,10 +787,15 @@ you need the code → status mapping yourself.
787
787
  > transient errors.
788
788
 
789
789
  Schema validation is enforced **by MongoDB** — the DB `$jsonSchema` validator
790
- derived from your schema is installed with `createSchema` (and hot-swapped with
791
- `updateSchema`), and the DB rejects violating writes with `VALIDATION_FAILED`.
792
- ninox deliberately does **not** validate *incoming user input* at runtime (no
793
- client-side/zod layer).
790
+ derived from your schema is installed with `createSchema`, and the DB rejects
791
+ violating writes with `VALIDATION_FAILED`. `createSchema` is **idempotent and
792
+ self-reconciling**: it provisions a NEW collection, and when the collection
793
+ already exists it upgrades the DB schema to match the current ORM schema
794
+ (hot-swapping the validator via `collMod` only when it has drifted, and
795
+ creating any newly-declared indexes) — so calling it on every boot keeps
796
+ Mongo's schema in lockstep with your models as they change. `updateSchema`
797
+ hot-swaps a validator explicitly (via `collMod`). ninox deliberately does
798
+ **not** validate *incoming user input* at runtime (no client-side/zod layer).
794
799
 
795
800
  - **Strict by default** — `additionalProperties: false` is emitted unless a
796
801
  field opts out (`s.object({ ... }, { additionalProperties: true })`), so
@@ -814,7 +819,8 @@ client-side/zod layer).
814
819
  `DomainError` carries the offending field paths (`extra.fields`), the failing
815
820
  document id (`extra.documentId`), and the raw `extra.details`, so a rejected
816
821
  write names exactly what (and which document) violated the schema.
817
- - **Index lifecycle** — `createSchema` installs the declared indexes;
822
+ - **Index lifecycle** — `createSchema` installs the declared indexes (and, on
823
+ an existing collection, adds any newly-declared ones without dropping);
818
824
  `syncIndexes(collection)` reconciles drift (`await db.syncIndexes('products')`
819
825
  creates missing declared keys and drops undeclared ones, `_id_` kept).
820
826
 
@@ -1015,9 +1021,10 @@ unknown keys in object literals AND non-literal patches. The only way an unknown
1015
1021
  key passes is a variable explicitly typed with an index signature
1016
1022
  (`Record<string, any>`) — prefer inline literals for full checking.
1017
1023
 
1018
- **Index drift.** `createSchema` installs declared indexes; if they drift out of
1019
- band (manual drops, other tooling), `syncIndexes('collection')` reconciles —
1020
- creates missing declared indexes and drops undeclared ones (`_id_` kept).
1024
+ **Index drift.** `createSchema` installs declared indexes on creation and adds
1025
+ newly-declared ones on re-provision; if indexes drift out of band (manual
1026
+ drops, other tooling), `syncIndexes('collection')` reconciles — creates
1027
+ missing declared indexes and drops undeclared ones (`_id_` kept).
1021
1028
 
1022
1029
  **Observability.** `QueryCache.stats()` (hits/misses/evictions/…) and
1023
1030
  `hot.stats()` (per-query hits/misses/refreshes/loadErrors/evictions/…) give you
package/dist/index.d.ts CHANGED
@@ -1467,6 +1467,13 @@ interface DbOpMeta {
1467
1467
  physicalCollection?: string;
1468
1468
  db: string;
1469
1469
  op: string;
1470
+ /**
1471
+ * What was sent to the database (filter / document / update / pipeline /
1472
+ * options). Surfaced in the ignex debugbar as the span's `params`, so the
1473
+ * dashboard shows exactly what the query carried — not just its name.
1474
+ * Optional: ops without a payload worth showing omit it.
1475
+ */
1476
+ params?: unknown;
1470
1477
  }
1471
1478
 
1472
1479
  /**
@@ -1549,7 +1556,7 @@ interface TextSearchPagination {
1549
1556
 
1550
1557
  interface CrudOpDeps<TCollection> {
1551
1558
  trace: <T>(meta: DbOpMeta, fn: () => T | Promise<T>) => Promise<T>;
1552
- meta: (collection: TCollection, op: string) => DbOpMeta;
1559
+ meta: (collection: TCollection, op: string, params?: unknown) => DbOpMeta;
1553
1560
  }
1554
1561
 
1555
1562
  /**
@@ -2420,9 +2427,19 @@ interface SchemaOpsOptions {
2420
2427
  getDefinition: (logical: string) => CollectionRegistryEntry | undefined;
2421
2428
  }
2422
2429
  /**
2423
- * Schema-first collection management. `createSchema` provisions a collection
2424
- * with a `$jsonSchema` validator derived from the ORM schema (and creates any
2425
- * declared indexes); `updateSchema` hot-swaps the validator via `collMod`.
2430
+ * Schema-first collection management.
2431
+ *
2432
+ * `createSchema` is IDEMPOTENT and SELF-RECONCILING: on a NEW collection it
2433
+ * provisions the `$jsonSchema` validator derived from the ORM schema plus the
2434
+ * declared indexes; when the collection ALREADY exists it upgrades the DB
2435
+ * schema to match the current ORM schema — hot-swapping the validator via
2436
+ * `collMod` only when it has actually drifted, and creating any newly-declared
2437
+ * indexes. It never drops indexes or data (destructive reconciliation stays
2438
+ * explicit via `syncIndexes`), so it is safe to call on every boot: the Mongo
2439
+ * schema follows the app schema automatically as models change.
2440
+ *
2441
+ * `updateSchema` hot-swaps a validator explicitly (via `collMod`);
2442
+ * `syncIndexes` reconciles index drift (create missing, drop undeclared).
2426
2443
  */
2427
2444
  declare const makeSchemaOps: (client: Db, _logger: LoggerLike, opts: SchemaOpsOptions) => {
2428
2445
  createSchema: (collection: string, createCollectionOptions?: CreateCollectionOptions) => Promise<void>;
package/dist/index.js CHANGED
@@ -3142,13 +3142,28 @@ var probeDebugQuery = async () => {
3142
3142
  debugQueryCache = null;
3143
3143
  }
3144
3144
  };
3145
+ var sanitizeDebugParams = (value) => {
3146
+ try {
3147
+ return JSON.parse(JSON.stringify(value));
3148
+ } catch {
3149
+ try {
3150
+ return String(value);
3151
+ } catch {
3152
+ return "[unserializable]";
3153
+ }
3154
+ }
3155
+ };
3145
3156
  var traceDbOp = async (logger, meta, fn, options = {}) => {
3146
3157
  const start = performance.now();
3147
3158
  logger.debug({ ...meta, operation: "start" }, "start");
3148
3159
  try {
3149
- if (debugQueryCache === void 0) await probeDebugQuery();
3150
- const debugQuery = debugQueryCache;
3151
- const result = debugQuery ? await debugQuery(`${meta.collection}.${meta.op}`, void 0, () => fn()) : await fn();
3160
+ let debugQuery = options.debugQuery;
3161
+ if (debugQuery === void 0) {
3162
+ if (debugQueryCache === void 0) await probeDebugQuery();
3163
+ debugQuery = debugQueryCache;
3164
+ }
3165
+ const params = meta.params === void 0 ? void 0 : sanitizeDebugParams(meta.params);
3166
+ const result = debugQuery ? await debugQuery(`${meta.collection}.${meta.op}`, params, () => fn()) : await fn();
3152
3167
  const durationMs = performance.now() - start;
3153
3168
  logger.info({ ...meta, durationMs }, "ok");
3154
3169
  return result;
@@ -3164,11 +3179,12 @@ var traceDbOp = async (logger, meta, fn, options = {}) => {
3164
3179
  // src/service/op-deps.ts
3165
3180
  var makeOpDeps = (logger, dbLabel, resolve, wrapMongoErrors) => {
3166
3181
  const trace = (meta2, fn) => traceDbOp(logger, meta2, fn, { wrapMongoErrors });
3167
- const meta = (collection, op) => ({
3182
+ const meta = (collection, op, params) => ({
3168
3183
  collection: String(collection),
3169
3184
  physicalCollection: resolve(String(collection)),
3170
3185
  db: dbLabel,
3171
- op
3186
+ op,
3187
+ ...params !== void 0 ? { params } : {}
3172
3188
  });
3173
3189
  return { trace, meta };
3174
3190
  };
@@ -3214,11 +3230,11 @@ var resolveDriftMode = (perOp, serviceDefault) => {
3214
3230
  };
3215
3231
 
3216
3232
  // src/service/crud-op.ts
3217
- var defineCrudOp = async (deps2, collection, opName, execute, options, write = false) => {
3233
+ var defineCrudOp = async (deps2, collection, opName, execute, options, write = false, params) => {
3218
3234
  const resolved = resolveQueryOptions(options);
3219
3235
  const maxAttempts = write && resolved.retryWrites !== true ? 1 : resolved.maxAttempts;
3220
3236
  return deps2.trace(
3221
- deps2.meta(collection, opName),
3237
+ deps2.meta(collection, opName, params),
3222
3238
  async () => withRetry(async () => execute(resolved), {
3223
3239
  maxAttempts,
3224
3240
  delayMs: resolved.retryDelayMs
@@ -3323,7 +3339,9 @@ var PipelineBuilder = class _PipelineBuilder {
3323
3339
  this.deps.logical,
3324
3340
  "mongo.pipeline",
3325
3341
  run,
3326
- this.deps.options
3342
+ this.deps.options,
3343
+ false,
3344
+ { pipeline: this.stages }
3327
3345
  );
3328
3346
  }
3329
3347
  /** Run the pipeline and return the first output document (or null). */
@@ -3350,7 +3368,9 @@ var PipelineBuilder = class _PipelineBuilder {
3350
3368
  this.deps.logical,
3351
3369
  "mongo.pipeline.first",
3352
3370
  run,
3353
- this.deps.options
3371
+ this.deps.options,
3372
+ false,
3373
+ { pipeline: this.stages }
3354
3374
  );
3355
3375
  }
3356
3376
  /** Streaming cursor (raw driver cursor; no retry / trace). */
@@ -3470,7 +3490,9 @@ var makeAggregateOps = (ctx) => {
3470
3490
  collection,
3471
3491
  "mongo.aggregate",
3472
3492
  (r) => coll(collection).aggregate(pipeline2, mergeAggOptions2(r.driverOpts, r.sdk)),
3473
- options
3493
+ options,
3494
+ false,
3495
+ { pipeline: pipeline2 }
3474
3496
  );
3475
3497
  };
3476
3498
  const pipeline = (collection, options) => new PipelineBuilder({
@@ -3556,7 +3578,7 @@ var createCachedAggregate = (ctx) => {
3556
3578
  const noSession = !resolved.sdk.session;
3557
3579
  const useCache = cache !== void 0 && noSession && resolved.cache !== false && isCacheablePipeline(pipeline);
3558
3580
  const shouldDedupe = resolved.dedupe !== false && (resolved.dedupe === true || opts.dedupeReads === true && noSession) && opts.inFlight !== void 0;
3559
- const runDriver = () => defineCrudOp(deps2, collection, opName, execute, options);
3581
+ const runDriver = () => defineCrudOp(deps2, collection, opName, execute, options, false, { pipeline });
3560
3582
  const key = useCache ? cache.key(
3561
3583
  cacheCollectionKey(client.databaseName, physical),
3562
3584
  stableHash([opName, pipeline, options])
@@ -3905,8 +3927,8 @@ var createCrudContext = (client, dbLabel, logger, opts) => {
3905
3927
  );
3906
3928
  const coll = (name) => client.collection(resolve(String(name)));
3907
3929
  const toDriverFilter = (filter) => filter;
3908
- const run = (collection, opName, execute, options) => defineCrudOp(deps2, collection, opName, execute, options);
3909
- const writeRun = (collection, opName, execute, options) => defineCrudOp(deps2, collection, opName, execute, options, true);
3930
+ const run = (collection, opName, execute, options, params) => defineCrudOp(deps2, collection, opName, execute, options, false, params);
3931
+ const writeRun = (collection, opName, execute, options, params) => defineCrudOp(deps2, collection, opName, execute, options, true, params);
3910
3932
  const invalidate = (collection) => {
3911
3933
  opts.cache?.invalidateByCollection(
3912
3934
  cacheCollectionKey(client.databaseName, resolve(String(collection)))
@@ -3942,6 +3964,9 @@ var createCrudContext = (client, dbLabel, logger, opts) => {
3942
3964
  const read = async (collection, opName, filter, options, execute, shape = "none", keyExtra) => {
3943
3965
  const physical = resolve(String(collection));
3944
3966
  const resolved = resolveQueryOptions(options);
3967
+ const sentParams = { filter };
3968
+ if (Object.keys(resolved.driverOpts).length > 0) sentParams.options = resolved.driverOpts;
3969
+ if (keyExtra !== void 0) sentParams.field = keyExtra;
3945
3970
  const noSession = !resolved.sdk.session;
3946
3971
  const useCache = opts.cache !== void 0 && noSession && resolved.cache !== false;
3947
3972
  const shouldDedupe = resolved.dedupe !== false && (resolved.dedupe === true || opts.dedupeReads === true && noSession) && opts.inFlight !== void 0;
@@ -3963,7 +3988,7 @@ var createCrudContext = (client, dbLabel, logger, opts) => {
3963
3988
  );
3964
3989
  };
3965
3990
  const executeWrapped = () => deps2.trace(
3966
- deps2.meta(collection, opName),
3991
+ deps2.meta(collection, opName, sentParams),
3967
3992
  () => withRetry(() => execute(resolved), {
3968
3993
  maxAttempts: resolved.maxAttempts,
3969
3994
  delayMs: resolved.retryDelayMs
@@ -4292,7 +4317,8 @@ var makeWriteOps = (ctx, readOps) => {
4292
4317
  insertOpts
4293
4318
  );
4294
4319
  },
4295
- options
4320
+ options,
4321
+ { doc }
4296
4322
  );
4297
4323
  invalidate(collection);
4298
4324
  await safeAfterHooks(String(collection), "afterCreate", {
@@ -4325,7 +4351,8 @@ var makeWriteOps = (ctx, readOps) => {
4325
4351
  bulkOpts
4326
4352
  );
4327
4353
  },
4328
- options
4354
+ options,
4355
+ { docs }
4329
4356
  );
4330
4357
  invalidate(collection);
4331
4358
  await safeAfterHooks(String(collection), "afterCreate", {
@@ -4354,7 +4381,8 @@ var makeWriteOps = (ctx, readOps) => {
4354
4381
  updateOpts
4355
4382
  );
4356
4383
  },
4357
- options
4384
+ options,
4385
+ { filter, update: effective }
4358
4386
  );
4359
4387
  invalidate(collection);
4360
4388
  await safeAfterHooks(String(collection), "afterUpdate", {
@@ -4383,7 +4411,8 @@ var makeWriteOps = (ctx, readOps) => {
4383
4411
  updateOpts
4384
4412
  );
4385
4413
  },
4386
- options
4414
+ options,
4415
+ { filter, update: effective }
4387
4416
  );
4388
4417
  invalidate(collection);
4389
4418
  await safeAfterHooks(String(collection), "afterUpdate", {
@@ -4416,7 +4445,8 @@ var makeWriteOps = (ctx, readOps) => {
4416
4445
  fOpts
4417
4446
  );
4418
4447
  },
4419
- options
4448
+ options,
4449
+ { filter, update: effective }
4420
4450
  );
4421
4451
  invalidate(collection);
4422
4452
  await safeAfterHooks(String(collection), "afterUpdate", {
@@ -4449,7 +4479,8 @@ var makeWriteOps = (ctx, readOps) => {
4449
4479
  fOpts
4450
4480
  );
4451
4481
  },
4452
- options
4482
+ options,
4483
+ { filter, replacement: effective }
4453
4484
  );
4454
4485
  invalidate(collection);
4455
4486
  await safeAfterHooks(String(collection), "afterUpdate", {
@@ -4477,7 +4508,8 @@ var makeWriteOps = (ctx, readOps) => {
4477
4508
  repOpts
4478
4509
  );
4479
4510
  },
4480
- options
4511
+ options,
4512
+ { filter, replacement }
4481
4513
  );
4482
4514
  invalidate(collection);
4483
4515
  await safeAfterHooks(String(collection), "afterUpdate", {
@@ -4501,7 +4533,8 @@ var makeWriteOps = (ctx, readOps) => {
4501
4533
  };
4502
4534
  return await coll(collection).deleteOne(toDriverFilter(filter), delOpts);
4503
4535
  },
4504
- options
4536
+ options,
4537
+ { filter }
4505
4538
  );
4506
4539
  invalidate(collection);
4507
4540
  await safeAfterHooks(String(collection), "afterDelete", {
@@ -4525,7 +4558,8 @@ var makeWriteOps = (ctx, readOps) => {
4525
4558
  };
4526
4559
  return await coll(collection).deleteMany(toDriverFilter(filter), delOpts);
4527
4560
  },
4528
- options
4561
+ options,
4562
+ { filter }
4529
4563
  );
4530
4564
  invalidate(collection);
4531
4565
  await safeAfterHooks(String(collection), "afterDelete", {
@@ -4554,7 +4588,8 @@ var makeWriteOps = (ctx, readOps) => {
4554
4588
  fOpts
4555
4589
  );
4556
4590
  },
4557
- options
4591
+ options,
4592
+ { filter }
4558
4593
  );
4559
4594
  invalidate(collection);
4560
4595
  await safeAfterHooks(String(collection), "afterDelete", {
@@ -4586,7 +4621,8 @@ var makeWriteOps = (ctx, readOps) => {
4586
4621
  updateOpts
4587
4622
  );
4588
4623
  },
4589
- options
4624
+ options,
4625
+ { filter, update: effective }
4590
4626
  );
4591
4627
  invalidate(collection);
4592
4628
  await safeAfterHooks(String(collection), "afterDelete", {
@@ -4612,7 +4648,8 @@ var makeWriteOps = (ctx, readOps) => {
4612
4648
  };
4613
4649
  return await coll(collection).updateOne(toDriverFilter(filter), formatted, updateOpts);
4614
4650
  },
4615
- options
4651
+ options,
4652
+ { filter, update: formatted }
4616
4653
  );
4617
4654
  invalidate(collection);
4618
4655
  await safeAfterHooks(String(collection), "afterUpdate", {
@@ -4644,7 +4681,8 @@ var makeWriteOps = (ctx, readOps) => {
4644
4681
  };
4645
4682
  return await coll(collection).bulkWrite(writes, bulkOpts);
4646
4683
  },
4647
- options
4684
+ options,
4685
+ { operations }
4648
4686
  );
4649
4687
  invalidate(collection);
4650
4688
  return result;
@@ -4665,7 +4703,8 @@ var makeWriteOps = (ctx, readOps) => {
4665
4703
  };
4666
4704
  return await coll(collection).bulkWrite(operations, bulkOpts);
4667
4705
  },
4668
- options
4706
+ options,
4707
+ { operations }
4669
4708
  );
4670
4709
  invalidate(collection);
4671
4710
  return result;
@@ -4848,7 +4887,9 @@ var makePaginateCursor = (ctx) => {
4848
4887
  };
4849
4888
  return client.collection(resolve(String(collection))).find(filter, findOpts).sort(effectiveSort).limit(limit + 1).toArray();
4850
4889
  },
4851
- config.queryOptions
4890
+ config.queryOptions,
4891
+ false,
4892
+ { filter, sort: effectiveSort, limit }
4852
4893
  );
4853
4894
  const hasMore = rows.length > limit;
4854
4895
  const page = backwards ? rows.slice(0, limit).reverse() : rows.slice(0, limit);
@@ -4974,40 +5015,63 @@ var optionSignature = (o) => JSON.stringify([
4974
5015
  ]);
4975
5016
  var indexKeyOf = (key, options) => `${JSON.stringify(Object.keys(key).map((k) => [k, key[k]]))}|${optionSignature(options)}`;
4976
5017
  var makeSchemaOps = (client, _logger, opts) => {
5018
+ const readCollectionValidator = async (physical) => {
5019
+ const info = await client.listCollections({ name: physical }).next();
5020
+ return info?.options?.validator;
5021
+ };
5022
+ const ensureDeclaredIndexes = async (collection, physical, def) => {
5023
+ const declared = def?.indexes ?? [];
5024
+ if (declared.length === 0) return;
5025
+ const handle = client.collection(physical);
5026
+ const existing = new Set(
5027
+ (await handle.listIndexes().toArray()).filter((i) => i.name !== "_id_").map((i) => indexKeyOf(i.key, i))
5028
+ );
5029
+ for (const index of declared) {
5030
+ if (existing.has(indexKeyOf(index.key, index.options))) continue;
5031
+ try {
5032
+ await handle.createIndex(index.key, index.options);
5033
+ } catch (err) {
5034
+ if (err?.code === 85) {
5035
+ throw new Error(
5036
+ `Cannot apply declared index on "${collection}": an index with the same name/key already exists with different options. Run db.syncIndexes("${collection}") to reconcile index drift.`,
5037
+ { cause: err }
5038
+ );
5039
+ }
5040
+ throw err;
5041
+ }
5042
+ }
5043
+ };
4977
5044
  const createSchema = async (collection, createCollectionOptions) => {
4978
5045
  const physical = opts.resolveCollectionName(collection);
4979
5046
  const def = opts.getDefinition(collection);
4980
- const validator = def?.schema ? toMongoValidator(def.schema) : void 0;
5047
+ const createOptions = {
5048
+ ...createCollectionOptions ?? {},
5049
+ ...def?.schema ? { validator: toMongoValidator(def.schema) } : {}
5050
+ };
5051
+ let created = true;
4981
5052
  try {
4982
- await client.createCollection(physical, {
4983
- // Caller options FIRST, derived validator LAST — a stale `validator`
4984
- // key in the options must not clobber the schema-derived one.
4985
- ...createCollectionOptions ?? {},
4986
- ...validator ? { validator } : {}
4987
- });
5053
+ await client.createCollection(physical, createOptions);
4988
5054
  } catch (err) {
4989
- if (err?.code === 48) {
4990
- throw new DomainError("COLLECTION_EXISTS", `Collection "${collection}" already exists`, {
4991
- physicalCollection: physical
4992
- });
4993
- }
4994
- throw err;
5055
+ if (err?.code !== 48) throw err;
5056
+ created = false;
4995
5057
  }
4996
- const indexes = def?.indexes ?? [];
4997
- if (indexes.length > 0) {
5058
+ if (created) {
4998
5059
  try {
4999
- await client.collection(physical).createIndexes(
5000
- indexes.map((index) => ({
5001
- key: index.key,
5002
- ...index.options ?? {}
5003
- }))
5004
- );
5060
+ await ensureDeclaredIndexes(collection, physical, def);
5005
5061
  } catch (err) {
5006
5062
  await client.dropCollection(physical).catch(() => {
5007
5063
  });
5008
5064
  throw err;
5009
5065
  }
5066
+ return;
5067
+ }
5068
+ if (createOptions.validator) {
5069
+ const current = await readCollectionValidator(physical);
5070
+ if (JSON.stringify(current) !== JSON.stringify(createOptions.validator)) {
5071
+ await client.command({ collMod: physical, validator: createOptions.validator });
5072
+ }
5010
5073
  }
5074
+ await ensureDeclaredIndexes(collection, physical, def);
5011
5075
  };
5012
5076
  const updateSchema = async (collection, schema) => {
5013
5077
  const physical = opts.resolveCollectionName(collection);