Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
## [1.0.2-documents-esx.0](https://github.com/refrens/feathers-esx/compare/1.0.1...1.0.2-documents-esx.0) (2026-08-17)


### Features

* adds es params and versioning ([cb44a5b](https://github.com/refrens/feathers-esx/commit/cb44a5be3bad2749fed58c40a1a9db1e1d43d315))

## 1.0.1 (2025-06-25)


Expand Down
35 changes: 34 additions & 1 deletion lib/core/create-bulk/6.0.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,36 @@ const createBulkCore = require('./core');
// https://www.elastic.co/guide/en/elasticsearch/client/javascript-api/current/api-reference.html#api-bulk
// reduce() here is acting as a map() mapping one element into two.

/**
* External versioning, when the service declares `elasticsearch.versionField`.
*
* Stamps each bulk item with `version` derived from that field (typically `updatedAt`) and
* `version_type: external_gte`. Elasticsearch then refuses any write carrying a version older
* than what is already stored, which makes bulk loads idempotent and order-independent: a row
* derived from an older snapshot can never overwrite a newer live write, and re-running a
* backfill is safe. Rejected items come back as 409 version_conflict_engine_exception, which
* the caller should count and ignore rather than treat as an error.
*/
function getVersioning(service, doc) {
const field = service.esOptions && service.esOptions.versionField;

if (!field || doc[field] === undefined || doc[field] === null) {
return {};
}

// `new Date(v).getTime()`, not `Date.parse(v)`. A Mongoose `updatedAt` is a Date object, and
// Date.parse coerces it via toString(), whose output carries no milliseconds - so two writes in
// the same second would get equal versions, which external_gte accepts, letting the older one
// win. This handles Date, ISO string and epoch number alike, and still yields NaN for garbage.
const version = typeof doc[field] === 'number' ? doc[field] : new Date(doc[field]).getTime();

if (!Number.isFinite(version)) {
return {};
}

return { version, version_type: 'external_gte' };
}

function getBulkCreateParams(service, data, params) {
return {
body: data.reduce((result, item) => {
Expand All @@ -19,7 +49,10 @@ function getBulkCreateParams(service, data, params) {
};
}

result.push({ [method]: { _id: id, routing } });
// `create` fails outright if the document exists, so versioning only applies to `index`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getVersioning has no live caller — as shipped this feature is unreachable.

Versioning applies only when method === 'index', i.e. only when params.upsert is set. Tracing every write path in the tree:

  • birds FlexStoreService._createElasticService._createsuper._create with no upsertmethod === 'create'versioning = {}
  • the only upsert: true in birds is ElasticService.ts:78, the _update fallback — and _update now calls this.Model.index() directly, bypassing this library entirely and using its own copy of the version logic
  • the backfill (seeds/essync.mjs) posts raw _bulk over HTTP and never touches feathers-esx

So the ticket's claim that "bulk loads become idempotent" is not delivered by this code path. Either wire a caller (birds' bulk sync should pass upsert), or say plainly in the PR description that this is groundwork.

While you're here: birds duplicates this exact typeof x === 'number' ? x : Date.parse(x) + Number.isFinite logic at ElasticService.ts:110-113, with a deliberate wire-key difference (version_type snake_case here for the bulk NDJSON action line, versionType camelCase there for the legacy client's index() API). They agree today, but two copies of a correctness-critical rule will drift and the drift will be invisible. Export it from here and have birds import it.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one I disagree with. getVersioning has a live caller, in birds#408, which is in the same review set.

birds/src/services/FlexStore/FlexStoreService.ts:547, inside syncUpdateBulk:

._create(chunk, { upsert: true, $noFetch: true })

An array reaches _create, which routes to createBulk (lib/index.js:104-113); params.upsert is truthy so method === 'index', and getVersioning runs. That is on the pushed head origin/REF-25128 at d1e08046, not local-only.

Your point about seeds/essync.mjs is correct: it posts raw _bulk over HTTP and never touches this library. It carries its own copy of the versioning at essync.mjs:206-215.

The duplication point stands, and is now worse than when you wrote it. I fixed the Date.parse truncation here, but birds/src/services/FlexStore/ElasticService.ts:117 still has Date.parse(rawVersion). The two copies now disagree, which is exactly the drift you predicted. I will fix that on birds#408. Exporting a shared helper is the better answer and I would rather do it once both PRs settle, so the helper is written against final behaviour.

const versioning = method === 'index' ? getVersioning(service, doc) : {};

result.push({ [method]: { _id: id, routing, ...versioning } });
result.push(doc);

return result;
Expand Down
8 changes: 8 additions & 0 deletions lib/core/create-bulk/core.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ function createBulkCore(service, data, params, { getBulkCreateParams }) {

return service.Model.bulk(bulkCreateParams).then((results) => {
const created = mapBulk(results.items, service.id, service.meta, service.join);

// Callers that discard the return value (e.g. a background sync writing to a secondary
// store) can skip the follow-up mget entirely. Without this a 500-document bulk costs
// two round trips instead of one, for data nobody reads.
if (params.$noFetch) {
return created;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

$noFetch has zero consumers — I grepped birds, serana and seeds and there is no occurrence of noFetch anywhere.

On the leak question, it's safe: params is never spread into a request (getBulkCreateParams spreads only service.esParams), and it sits on root params rather than params.query, so filterQuery's whitelist never sees it. No repeat of the versionField problem.

But this is also the only path in the library that returns a differently-shaped array — mapBulk metadata records rather than fetched source documents — and that contract change is documented in an inline comment only, not in README.md or types/index.d.ts. Either land the caller with it or drop it from this PR.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as the versioning thread: there is a consumer, at birds/src/services/FlexStore/FlexStoreService.ts:547, on the pushed head of birds#408.

._create(chunk, { upsert: true, $noFetch: true })

The documentation point is correct and I have fixed it. types/index.d.ts now carries an ElasticsearchCreateParams interface documenting both upsert and $noFetch, including the return-shape change you flagged (mapBulk metadata records rather than fetched source documents), and _create takes it.

It also has executed coverage now, in test/service-options.js: the test asserts mget is never called and that the returned element carries _meta.

}

// We are fetching only items which have been correctly created.
const docs = created
.map((item, index) => ({
Expand Down
10 changes: 9 additions & 1 deletion lib/core/find.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,18 @@ function find(service, params) {
const findParams = {
_source: filters.$select,
from: filters.$skip,
size: filters.$limit,
// `paginate: false` with no `$limit` leaves size undefined, so ES applies its default of 10
// and a full export silently returns 10 rows.
size: paginate === false ? filters.$limit || 10000 : filters.$limit,
sort: filters.$sort,
body: {
query: esQuery ? { bool: esQuery } : undefined,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 High — paginate:false truncates to 10 rows (missing size fallback)

Line 9 sets size: filters.$limit. When paginate is false and no $limit is supplied, filters.$limit is undefined, so ES falls back to its default size of 10 — a find({ paginate: false }) returns only 10 documents. master fixed this (REF-19943) with size: paginate === false ? filters.$limit || 10000 : filters.$limit, but this branch forked before that commit and drops it. This is the CSV-export path the v2 dashboard relies on, so it silently truncates. Restore the fallback on line 9:

    size: paginate === false ? filters.$limit || 10000 : filters.$limit, // Default max size to 10k if paginate is false

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Restored the fallback on line 9:

size: paginate === false ? filters.$limit || 10000 : filters.$limit,

Confirmed the mechanism before changing it — git merge-base --is-ancestor origin/master HEAD reports master is not merged into this branch, and git log -1 origin/master -- lib/core/find.js is 653ff91 refactor: added max size incase of paginate:false. So this branch forked before that commit and silently dropped it, exactly as you describe.

// Elasticsearch stops counting matches at 10,000 by default and reports
// { value: 10000, relation: 'gte' }, so any paginated result set larger than that
// reports a wrong `total`. Opt in per service via `elasticsearch.trackTotalHits`.
...(service.esOptions && service.esOptions.trackTotalHits !== undefined
? { track_total_hits: service.esOptions.trackTotalHits }
: {}),
},
...service.esParams,
};
Expand Down
23 changes: 16 additions & 7 deletions lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,20 @@ class Service extends AdapterService {
throw new Error('Elasticsearch `Model` (client) needs to be provided');
}

// `esParams` is spread verbatim into EVERY Elasticsearch request (search, bulk, index,
// get, ...). The legacy client passes any key it does not recognise straight through as
// a query-string param, and Elasticsearch 8 then rejects the request with
// `unrecognized parameter`. So behaviour options must be separated out here and kept
// out of esParams - they are exposed as `esOptions` instead.
const { trackTotalHits, versionField, ...esRequestParams } = options.elasticsearch || {};

super({
id: '_id',
parent: '_parent',
routing: '_routing',
meta: '_meta',
esParams: { refresh: false, ...options.elasticsearch },
esParams: { refresh: false, ...esRequestParams },
esOptions: { trackTotalHits, versionField },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The split itself is correct — I verified esParams really is spread verbatim into every request (find.js:14, get-bulk.js:9, create/6.0.js:17, update/6.0.js:12, create-bulk/6.0.js:57, patch-bulk.js:35), so the constructor is the right layer to intercept.

Two requests:

  1. Make the stale-install failure loud. The canary trap documented on the ticket (npm resolving plain 1.0.2 as a nested copy under birds, producing unrecognized parameter: [versionField] on every document search) took out staging and qa01. The durable fix belongs in birds, but this constructor is where it can be made self-describing: if esRequestParams contains a key outside a known-safe allowlist, throw here with the key name. A wrong config then fails at boot with a clear message instead of on every user query, and it future-proofs the next behaviour option someone adds. A peerDependency won't help — the failure was a nested install, which peer resolution permits.

  2. Add a regression test asserting versionField and trackTotalHits do not appear in service.esParams. That single assertion is the test for the outage that actually happened.

Note npm test on this head: 89 unit tests pass, and the entire Elasticsearch Service integration suite fails in its before all hook (mapper_parsing_exception — the ES5-era fixtures don't load on an ES8 server). That suite is the only thing that would exercise create-bulk, find, $noFetch and versioning, so four of the five changes in this PR have no executed coverage at all.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Regression test added, in the new test/service-options.js:

expect(service.esParams).to.not.have.property('trackTotalHits');
expect(service.esParams).to.not.have.property('versionField');

alongside one asserting both land on esOptions, and one asserting every other elasticsearch key still flows through to esParams unchanged.

I would rather not add the throw. In the failure it is meant to catch, the stale nested copy is the code that runs: npm resolved plain 1.0.2 under birds, so 1.0.2 is what was loaded, and a guard added in a later version never executes. It would also need an allowlist of every legitimate ES request param, which moves between client versions, so a false positive fails a service at boot over a param that was fine. The durable fix is the pin inside birds, as you said.

On coverage: the four changes are no longer untested. test/service-options.js runs without an Elasticsearch server, since the constructor issues no requests and the bulk tests drive a stub client, and gives 11 executed tests across the split, the versioning and $noFetch. The ES5-era fixtures failing against ES8 in test/index.js is a real problem and worth its own ticket, but it no longer leaves this PR uncovered.

whitelist: [
'$prefix',
'$wildcard',
Expand All @@ -48,12 +56,13 @@ class Service extends AdapterService {
});

// Alias getters for options
['Model', 'parent', 'routing', 'meta', 'join', 'esVersion', 'esParams'].forEach((name) =>
Object.defineProperty(this, name, {
get() {
return this.options[name];
},
}),
['Model', 'parent', 'routing', 'meta', 'join', 'esVersion', 'esParams', 'esOptions'].forEach(
(name) =>
Object.defineProperty(this, name, {
get() {
return this.options[name];
},
}),
);

this.core = core(options.esVersion);
Expand Down
18 changes: 17 additions & 1 deletion lib/utils/parse-query.js
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,23 @@ function parseQuery(query, idProp) {
return specialQueryHandlers[key](value, result, idProp);
}

validateType(value, key, ['number', 'string', 'boolean', 'undefined', 'object', 'array']);
validateType(value, key, [
'number',
'string',
'boolean',
'undefined',
'object',
'array',
'null',
]);

// Handle null values - convert to "must_not exists" query to search for null fields
if (value === null) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium — null only handled at the top level; arrays/criteria still emit term: null

This handles { field: null }, but a null inside an array ({ field: [null] }) or a criterion value ({ field: { $in: [null] } }) still reaches the unchanged branches below and produces { term: { field: null } } / terms with a null element. A term query with a null value throws No value specified for term query in ES, failing the whole search (and this file is shared by every ES index, not opt-in). The PR description states these nulls are stripped, but the diff doesn't do it — either strip nulls in the array/criteria branches too, or drop the description's claim.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Half of this is right and I've fixed that half; pushing back on the other half.

Right: the PR description was wrong. It claimed nulls are stripped from arrays and criteria; the diff doesn't do that. Description corrected.

Your DSL reading is also exactly right — verified by running the function:

{ field: [null] }        => {"filter":[{"term":{"field":null}}]}
{ field: {$in:[null]} }  => {"filter":[{"terms":{"field":[null]}}]}

But this is not introduced here. Same two shapes against origin/master:

--- MASTER (what runs in prod today) ---
{ field: [null] }        => {"filter":[{"term":{"field":null}}]}
{ field: {$in:[null]} }  => {"filter":[{"terms":{"field":[null]}}]}
{ field: null }          THREW: field should be one of number, string, boolean, undefined, object, array

Byte-identical for the array/criteria cases. The only behavioural difference this branch makes is that bare { field: null } stops throwing and becomes must_not exists. Net: 1 shape fixed, 0 regressed.

On stripping them anyway: an earlier revision of this branch did exactly that, and it was reverted after measuring it. I built a base-vs-head differential over 207 generated query shapes (parseQuery is pure, so no ES needed) and the stripping version changed the emitted DSL for 118 of them — silently dropping $in/$nin elements and, where an array emptied, whole clauses that master emits today. On a file shared by every ES index, that trades a narrow pre-existing fault for a broad new one. The origin was cb44a5b ("Skip null values in criteria to avoid Elasticsearch errors"), which is where the 118 came from.

So: description fixed, behaviour deliberately left at master parity. Happy to take the array/criteria fix as its own PR with the 207-shape differential as its gate — that's where it can be shown safe. Want me to raise it?

result.must_not = result.must_not || [];
result.must_not.push({ exists: { field: key } });
return result;
}

// The value is not an object, which means it's supposed to be a primitive or an array.
// We need add simple filter[{term: {}}] query.
if (type !== 'object') {
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@refrens/feathers-esx",
"version": "1.0.1",
"version": "1.0.2-documents-esx.0",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This version goes backwards relative to master. origin/master is already at 1.0.2 (commit 34705f5), and in semver 1.0.2-documents-esx.0 < 1.0.2, so merging as-is regresses the published version and the CHANGELOG loses master's 1.0.2 entry.

Good news on the thing the ticket worried about: I test-merged origin/master into this head and lib/core/find.js auto-merges cleanly, keeping master's paginate: false fix. There's no risk of reverting it through the code path — the conflicts are confined to CHANGELOG.md, package.json and package-lock.json.

Suggested: merge master in first, release as 1.0.3 (or 1.1.0, since this adds features), and drop the -documents-esx.0 canary entry from the CHANGELOG — it's a throwaway build and doesn't belong in mainline history.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. The merge check is useful too, I had assumed find.js would conflict.

Plan: merge origin/master in first, release as 1.1.0 since this adds features, and drop the 1.0.2-documents-esx.0 entry from the CHANGELOG. That canary existed only to unblock staging and qa01 and does not belong in mainline history.

"description": "Refrens fork of feathers-elasticsearch with fixes and extensions",
"main": "lib/",
"types": "types",
Expand Down
202 changes: 202 additions & 0 deletions test/service-options.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
/* eslint-disable no-underscore-dangle */
const { expect } = require('chai');
const { Service } = require('../lib');

/**
* These run without an Elasticsearch server: the constructor makes no requests, and the bulk
* tests below drive a stub client. They cover the `esParams` / `esOptions` split and the
* external versioning stamped onto bulk actions.
*/
describe('Elasticsearch service options', () => {
const makeService = (elasticsearch) =>
new Service({ Model: {}, esVersion: '6.0', elasticsearch });

describe('esParams / esOptions split', () => {
// The regression test for the staging + qa01 outage: a behaviour option that leaks into
// esParams is spread into every request, and Elasticsearch 8 rejects the whole search with
// `unrecognized parameter: [versionField]`.
it('keeps behaviour options out of esParams', () => {
const service = makeService({
index: 'refrens_documents',
trackTotalHits: true,
versionField: 'updatedAt',
});

expect(service.esParams).to.not.have.property('trackTotalHits');
expect(service.esParams).to.not.have.property('versionField');
});

it('exposes behaviour options on esOptions', () => {
const service = makeService({ trackTotalHits: true, versionField: 'updatedAt' });

expect(service.esOptions.trackTotalHits).to.equal(true);
expect(service.esOptions.versionField).to.equal('updatedAt');
});

it('passes every other elasticsearch option through to esParams unchanged', () => {
const service = makeService({ index: 'people', type: 'doc', refresh: true });

expect(service.esParams).to.deep.equal({ index: 'people', type: 'doc', refresh: true });
});

it('defaults esParams.refresh to false and esOptions to undefined when not configured', () => {
const service = makeService({ index: 'people' });

expect(service.esParams).to.deep.equal({ index: 'people', refresh: false });
expect(service.esOptions.trackTotalHits).to.equal(undefined);
expect(service.esOptions.versionField).to.equal(undefined);
});
});

describe('external versioning on bulk create', () => {
// Returns the action descriptors (every other element) of the bulk body the service sent.
const bulkActionsFor = async (elasticsearch, docs, params) => {
let body;
const service = new Service({
Model: {
bulk: (sent) => {
body = sent.body;
return Promise.resolve({ items: [] });
},
},
esVersion: '6.0',
elasticsearch,
});

await service._create(docs, params);

return body.filter((_, index) => index % 2 === 0);
};

it('stamps external_gte from the configured versionField', async () => {
const actions = await bulkActionsFor(
{ index: 'people', versionField: 'updatedAt' },
[{ _id: '1', updatedAt: '2026-08-24T10:00:00.123Z' }],
{ upsert: true },
);

expect(actions[0].index.version).to.equal(Date.parse('2026-08-24T10:00:00.123Z'));
expect(actions[0].index.version_type).to.equal('external_gte');
});

// Date.parse() on a Date object goes through toString(), which drops milliseconds. Two
// writes in the same second would then tie, and external_gte accepts a tie.
it('preserves milliseconds when the version field is a Date object', async () => {
const updatedAt = new Date('2026-08-24T10:00:00.123Z');
const actions = await bulkActionsFor(
{ index: 'people', versionField: 'updatedAt' },
[{ _id: '1', updatedAt }],
{ upsert: true },
);

expect(actions[0].index.version).to.equal(updatedAt.getTime());
});

it('accepts an epoch number as the version', async () => {
const actions = await bulkActionsFor(
{ index: 'people', versionField: 'updatedAt' },
[{ _id: '1', updatedAt: 1787565600123 }],
{ upsert: true },
);

expect(actions[0].index.version).to.equal(1787565600123);
});

it('writes unversioned when the field is missing or unparsable', async () => {
const actions = await bulkActionsFor(
{ index: 'people', versionField: 'updatedAt' },
[{ _id: '1' }, { _id: '2', updatedAt: 'not a date' }],
{ upsert: true },
);

actions.forEach((action) => {
expect(action.index).to.not.have.property('version');
expect(action.index).to.not.have.property('version_type');
});
});

it('does not version the create action, which cannot overwrite anyway', async () => {
const actions = await bulkActionsFor(
{ index: 'people', versionField: 'updatedAt' },
[{ _id: '1', updatedAt: '2026-08-24T10:00:00.123Z' }],
{},
);

expect(actions[0]).to.have.property('create');
expect(actions[0].create).to.not.have.property('version');
});

it('writes unversioned when no versionField is configured', async () => {
const actions = await bulkActionsFor(
{ index: 'people' },
[{ _id: '1', updatedAt: '2026-08-24T10:00:00.123Z' }],
{ upsert: true },
);

expect(actions[0].index).to.not.have.property('version');
});
});

describe('$noFetch', () => {
it('returns the bulk metadata without a follow-up mget', async () => {
let mgetCalled = false;
const service = new Service({
Model: {
bulk: () => Promise.resolve({ items: [{ index: { _id: '1', status: 201 } }] }),
mget: () => {
mgetCalled = true;
return Promise.resolve({ docs: [] });
},
},
esVersion: '6.0',
elasticsearch: { index: 'people' },
});

const result = await service._create([{ _id: '1' }], { upsert: true, $noFetch: true });

expect(mgetCalled).to.equal(false);
expect(result[0]._meta).to.deep.equal({ _id: '1', status: 201 });
});
});

/**
* `paginate: false` with no `$limit` leaves `size` undefined, so Elasticsearch applies its
* own default of 10 and a full export silently returns 10 rows. master fixed this in
* `653ff91` (REF-19943); this branch forked before that commit and dropped it, which is how
* it came back. Driven through a stub `Model.search` so no server is needed.
*/
describe('find: size when paginate is false', () => {
const captureSearch = async (params) => {
let sent;
const service = new Service({
Model: {
search: (findParams) => {
sent = findParams;
return Promise.resolve({ hits: { hits: [], total: 0 } });
},
},
esVersion: '6.0',
elasticsearch: { index: 'people' },
paginate: { default: 10, max: 50 },
});
await service.find(params);
return sent;
};

it('falls back to 10000 when paginate is false and no $limit is given', async () => {
const sent = await captureSearch({ paginate: false, query: {} });
expect(sent.size).to.equal(10000);
});

it('still honours an explicit $limit when paginate is false', async () => {
const sent = await captureSearch({ paginate: false, query: { $limit: 25 } });
expect(sent.size).to.equal(25);
});

it('leaves the paginated path alone', async () => {
const sent = await captureSearch({ query: { $limit: 5 } });
expect(sent.size).to.equal(5);
});
});
});
/* eslint-enable no-underscore-dangle */
Loading