@byline/core 6.10.0 → 6.11.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.
@@ -7,8 +7,10 @@
7
7
  */
8
8
  /**
9
9
  * Comparison operators usable on a single field-level predicate value. The
10
- * parser normalises a bare value (`'foo'`) to `{ $eq: 'foo' }`; an operator
11
- * object is taken as-is and the first declared operator wins.
10
+ * parser normalises a bare value (`'foo'`) to `{ $eq: 'foo' }`. Every operator
11
+ * in an operator object applies and the conditions are ANDed, so
12
+ * `{ $gte: 10, $lte: 20 }` is a closed range. An empty object or an unknown
13
+ * operator is a validation error.
12
14
  */
13
15
  export interface FilterOperators {
14
16
  $eq?: string | number | boolean | null;
@@ -134,9 +134,13 @@ async function validateSecurityPredicate(predicate, definition, ctx, allowEmpty
134
134
  }
135
135
  function validateDocumentColumnPredicate(value, collectionPath, column) {
136
136
  validateSecurityOperator(value, collectionPath, column);
137
- const parsed = normaliseToOperator(value);
138
- if (parsed == null || !DOCUMENT_COLUMN_OPERATORS[column].has(parsed.operator)) {
139
- throw invalidSecurityPredicate(collectionPath, `operator '${parsed?.operator ?? 'unknown'}' is not supported for '${column}'`);
137
+ for (const parsed of normaliseToOperators(value, collectionPath, column)) {
138
+ validateDocumentColumnOperator(parsed, collectionPath, column);
139
+ }
140
+ }
141
+ function validateDocumentColumnOperator(parsed, collectionPath, column) {
142
+ if (!DOCUMENT_COLUMN_OPERATORS[column].has(parsed.operator)) {
143
+ throw invalidSecurityPredicate(collectionPath, `operator '${parsed.operator}' is not supported for '${column}'`);
140
144
  }
141
145
  const values = Array.isArray(parsed.value) ? parsed.value : [parsed.value];
142
146
  if ((parsed.operator === '$in' || parsed.operator === '$nin') && !Array.isArray(parsed.value)) {
@@ -163,18 +167,20 @@ function validateSecurityOperator(value, collectionPath, fieldName) {
163
167
  throw invalidSecurityPredicate(collectionPath, `invalid predicate for '${fieldName}'`);
164
168
  }
165
169
  const entries = Object.entries(value);
166
- if (entries.length !== 1 || !FILTER_OPERATORS.has(entries[0]?.[0] ?? '')) {
167
- throw invalidSecurityPredicate(collectionPath, `predicate for '${fieldName}' must contain exactly one supported operator`);
168
- }
169
- const [operator, operand] = entries[0] ?? [];
170
- if ((operator === '$in' || operator === '$nin') && !Array.isArray(operand)) {
171
- throw invalidSecurityPredicate(collectionPath, `${operator} for '${fieldName}' requires an array`);
172
- }
173
- if (operator === '$contains' && typeof operand !== 'string') {
174
- throw invalidSecurityPredicate(collectionPath, `$contains for '${fieldName}' requires a string`);
170
+ if (entries.length === 0 || entries.some(([operator]) => !FILTER_OPERATORS.has(operator))) {
171
+ throw invalidSecurityPredicate(collectionPath, `predicate for '${fieldName}' must contain only supported operators`);
175
172
  }
176
- if (operand === undefined) {
177
- throw invalidSecurityPredicate(collectionPath, `operator for '${fieldName}' requires a value`);
173
+ // Every operator in the object applies (ANDed), so every one is checked.
174
+ for (const [operator, operand] of entries) {
175
+ if ((operator === '$in' || operator === '$nin') && !Array.isArray(operand)) {
176
+ throw invalidSecurityPredicate(collectionPath, `${operator} for '${fieldName}' requires an array`);
177
+ }
178
+ if (operator === '$contains' && typeof operand !== 'string') {
179
+ throw invalidSecurityPredicate(collectionPath, `$contains for '${fieldName}' requires a string`);
180
+ }
181
+ if (operand === undefined) {
182
+ throw invalidSecurityPredicate(collectionPath, `operator for '${fieldName}' requires a value`);
183
+ }
178
184
  }
179
185
  }
180
186
  function invalidSecurityPredicate(collectionPath, reason) {
@@ -241,8 +247,7 @@ async function parseWhereInternal(where, definition, ctx, { isNested, inCombinat
241
247
  // belong at the top level of the where clause.
242
248
  if (key === '$and' || key === '$or') {
243
249
  if (!Array.isArray(rawValue)) {
244
- ctx?.logger?.debug({ key, collection: definition.path }, 'parse-where: dropping combinator — value is not an array of predicates');
245
- continue;
250
+ throw invalidWhere(definition.path, `${key} must be an array of predicates`);
246
251
  }
247
252
  const childFilters = [];
248
253
  for (const child of rawValue) {
@@ -311,9 +316,10 @@ async function parseWhereInternal(where, definition, ctx, { isNested, inCombinat
311
316
  // text search has no sensible OR-composing form, and a per-target
312
317
  // EXISTS over `store_text` is intentionally deferred.
313
318
  if (key === 'status') {
314
- if (isNested || inCombinator) {
315
- const parsed = normaliseToOperator(rawValue);
316
- if (parsed) {
319
+ // A bare string at the top level keeps the scalar `status` form. An
320
+ // operator object takes the `DocumentColumnFilter` form at any scope.
321
+ if (isNested || inCombinator || typeof rawValue !== 'string') {
322
+ for (const parsed of normaliseToOperators(rawValue, definition.path, key)) {
317
323
  result.filters.push({
318
324
  kind: 'docColumn',
319
325
  column: 'status',
@@ -323,9 +329,7 @@ async function parseWhereInternal(where, definition, ctx, { isNested, inCombinat
323
329
  }
324
330
  continue;
325
331
  }
326
- if (typeof rawValue === 'string') {
327
- result.status = rawValue;
328
- }
332
+ result.status = rawValue;
329
333
  continue;
330
334
  }
331
335
  if (key === 'query') {
@@ -343,9 +347,12 @@ async function parseWhereInternal(where, definition, ctx, { isNested, inCombinat
343
347
  continue;
344
348
  }
345
349
  if (key === 'path') {
346
- if (isNested || inCombinator) {
347
- const parsed = normaliseToOperator(rawValue);
348
- if (parsed) {
350
+ const operators = normaliseToOperators(rawValue, definition.path, key);
351
+ // `pathFilter` holds one operator. Several operators on a top-level
352
+ // `path` take the `DocumentColumnFilter` form instead, which the
353
+ // adapter resolves against the top-level scope just as well.
354
+ if (isNested || inCombinator || operators.length > 1) {
355
+ for (const parsed of operators) {
349
356
  result.filters.push({
350
357
  kind: 'docColumn',
351
358
  column: 'path',
@@ -355,7 +362,7 @@ async function parseWhereInternal(where, definition, ctx, { isNested, inCombinat
355
362
  }
356
363
  continue;
357
364
  }
358
- const parsed = normaliseToOperator(rawValue);
365
+ const [parsed] = operators;
359
366
  if (parsed) {
360
367
  result.pathFilter = {
361
368
  operator: parsed.operator,
@@ -373,8 +380,7 @@ async function parseWhereInternal(where, definition, ctx, { isNested, inCombinat
373
380
  // don't `String(...)` it the way status/path do, because the headline
374
381
  // use case is `$in` / `$nin` carrying an array of document ids.
375
382
  if (key === 'id') {
376
- const parsed = normaliseToOperator(rawValue);
377
- if (parsed) {
383
+ for (const parsed of normaliseToOperators(rawValue, definition.path, key)) {
378
384
  result.filters.push({
379
385
  kind: 'docColumn',
380
386
  column: 'id',
@@ -453,17 +459,16 @@ async function parseWhereInternal(where, definition, ctx, { isNested, inCombinat
453
459
  const storeInfo = fieldTypeToStore[field.type];
454
460
  if (!storeInfo)
455
461
  continue; // Structure fields can't be filtered directly
456
- const parsed = normaliseToOperator(rawValue);
457
- if (!parsed)
458
- continue;
459
- result.filters.push({
460
- kind: 'field',
461
- fieldName: key,
462
- storeType: storeInfo.storeType,
463
- valueColumn: storeInfo.valueColumn,
464
- operator: parsed.operator,
465
- value: parsed.value,
466
- });
462
+ for (const parsed of normaliseToOperators(rawValue, definition.path, key)) {
463
+ result.filters.push({
464
+ kind: 'field',
465
+ fieldName: key,
466
+ storeType: storeInfo.storeType,
467
+ valueColumn: storeInfo.valueColumn,
468
+ operator: parsed.operator,
469
+ value: parsed.value,
470
+ });
471
+ }
467
472
  }
468
473
  return result;
469
474
  }
@@ -555,7 +560,7 @@ function isPlainSubWhere(raw) {
555
560
  return false;
556
561
  const keys = Object.keys(raw);
557
562
  // An empty object is not a meaningful sub-where; treat as non-match
558
- // (ordinary field-filter path will then reject it via normaliseToOperator).
563
+ // (ordinary field-filter path will then reject it via normaliseToOperators).
559
564
  if (keys.length === 0)
560
565
  return false;
561
566
  for (const k of keys) {
@@ -565,28 +570,46 @@ function isPlainSubWhere(raw) {
565
570
  return true;
566
571
  }
567
572
  /**
568
- * Normalise a WhereValue (bare value or operator object) into a single
569
- * operator + value pair.
573
+ * Normalise a WhereValue (bare value or operator object) into a list of
574
+ * operator + value pairs. Every operator in an operator object applies, so
575
+ * `{ $gte: 10, $lte: 20 }` yields two pairs that the caller ANDs together.
576
+ * Operators whose value is `undefined` are skipped, matching how the parser
577
+ * skips `undefined` keys. An object with no operators left, or with an
578
+ * unknown operator, is a validation error rather than a dropped filter.
570
579
  */
571
- function normaliseToOperator(raw) {
580
+ function normaliseToOperators(raw, collectionPath, key) {
572
581
  // Bare value → $eq
573
582
  if (raw === null ||
574
583
  typeof raw === 'string' ||
575
584
  typeof raw === 'number' ||
576
585
  typeof raw === 'boolean') {
577
- return { operator: '$eq', value: raw };
586
+ return [{ operator: '$eq', value: raw }];
578
587
  }
579
- // Operator object
580
- const ops = raw;
581
- const operatorEntries = Object.entries(ops);
582
- if (operatorEntries.length === 0)
583
- return undefined;
584
- // Use the first operator found
585
- const [op, val] = operatorEntries[0];
586
- return {
588
+ if (typeof raw !== 'object' || Array.isArray(raw)) {
589
+ throw invalidWhere(collectionPath, `invalid value for '${key}'`);
590
+ }
591
+ // Operator object. Names are checked before `undefined` operands are
592
+ // skipped, so an unknown operator throws even when its value is missing.
593
+ const entries = Object.entries(raw);
594
+ for (const [op] of entries) {
595
+ if (!FILTER_OPERATORS.has(op)) {
596
+ throw invalidWhere(collectionPath, `unknown operator '${op}' for '${key}'`);
597
+ }
598
+ }
599
+ const operatorEntries = entries.filter(([, val]) => val !== undefined);
600
+ if (operatorEntries.length === 0) {
601
+ throw invalidWhere(collectionPath, `operator object for '${key}' is empty`);
602
+ }
603
+ return operatorEntries.map(([op, val]) => ({
587
604
  operator: op,
588
605
  value: val,
589
- };
606
+ }));
607
+ }
608
+ function invalidWhere(collectionPath, reason) {
609
+ return ERR_VALIDATION({
610
+ message: `invalid where clause for '${collectionPath}': ${reason}`,
611
+ details: { collectionPath, reason },
612
+ });
590
613
  }
591
614
  function stringifyDocumentColumnValue(value) {
592
615
  if (value === null)
@@ -522,12 +522,10 @@ describe('parseWhere — combinators', () => {
522
522
  // landmine if compiled to SQL.
523
523
  expect(result.filters).toEqual([]);
524
524
  });
525
- it('drops a non-array combinator value (defensive)', async () => {
526
- const result = await parseWhere({
527
- // biome-ignore lint/suspicious/noExplicitAny: deliberately malformed input
528
- $or: { title: 'Hello' },
529
- }, testCollection);
530
- expect(result.filters).toEqual([]);
525
+ it.each(['$and', '$or'])('rejects a non-array %s value', async (key) => {
526
+ await expect(parseWhere(
527
+ // biome-ignore lint/suspicious/noExplicitAny: deliberately malformed input
528
+ { [key]: { title: 'Hello' } }, testCollection)).rejects.toThrow(`${key} must be an array of predicates`);
531
529
  });
532
530
  it('nests $or inside $and correctly', async () => {
533
531
  const result = await parseWhere({
@@ -867,3 +865,132 @@ describe('parseWhere — relation quantifiers', () => {
867
865
  expect(result.filters).toHaveLength(0);
868
866
  });
869
867
  });
868
+ // ---------------------------------------------------------------------------
869
+ // Multi-operator objects
870
+ // ---------------------------------------------------------------------------
871
+ describe('parseWhere — multi-operator objects', () => {
872
+ const views = (operator, value) => ({
873
+ kind: 'field',
874
+ fieldName: 'views',
875
+ storeType: 'numeric',
876
+ valueColumn: 'value_integer',
877
+ operator,
878
+ value,
879
+ });
880
+ it('emits one filter per operator in a closed range', async () => {
881
+ const result = await parseWhere({ views: { $gte: 10, $lte: 20 } }, testCollection);
882
+ expect(result.filters).toEqual([views('$gte', 10), views('$lte', 20)]);
883
+ });
884
+ it('keeps every operator of the downstream date-range shape', async () => {
885
+ const result = await parseWhere({ views: { $gt: 0, $gte: 20200000, $lte: 20201231 } }, testCollection);
886
+ expect(result.filters).toEqual([
887
+ views('$gt', 0),
888
+ views('$gte', 20200000),
889
+ views('$lte', 20201231),
890
+ ]);
891
+ });
892
+ it('combines $eq with a range operator', async () => {
893
+ const result = await parseWhere({ views: { $eq: 42, $lt: 100 } }, testCollection);
894
+ expect(result.filters).toEqual([views('$eq', 42), views('$lt', 100)]);
895
+ });
896
+ it('skips operators whose value is undefined', async () => {
897
+ const result = await parseWhere({ views: { $gte: undefined, $lte: 20 } }, testCollection);
898
+ expect(result.filters).toEqual([views('$lte', 20)]);
899
+ });
900
+ it('keeps the $and workaround shape working', async () => {
901
+ const result = await parseWhere({ $and: [{ views: { $gt: 0 } }, { views: { $gte: 10 } }, { views: { $lte: 20 } }] }, testCollection);
902
+ expect(result.filters).toEqual([views('$gt', 0), views('$gte', 10), views('$lte', 20)]);
903
+ });
904
+ it('emits every operator on a document column', async () => {
905
+ const result = await parseWhere({ $or: [{ status: { $ne: 'archived', $in: ['draft', 'published'] } }] }, testCollection);
906
+ expect(result.filters).toEqual([
907
+ {
908
+ kind: 'or',
909
+ children: [
910
+ {
911
+ kind: 'and',
912
+ children: [
913
+ { kind: 'docColumn', column: 'status', operator: '$ne', value: 'archived' },
914
+ {
915
+ kind: 'docColumn',
916
+ column: 'status',
917
+ operator: '$in',
918
+ value: ['draft', 'published'],
919
+ },
920
+ ],
921
+ },
922
+ ],
923
+ },
924
+ ]);
925
+ });
926
+ it('compiles a top-level status operator object instead of dropping it', async () => {
927
+ const scalar = await parseWhere({ status: 'draft' }, testCollection);
928
+ expect(scalar.status).toBe('draft');
929
+ expect(scalar.filters).toEqual([]);
930
+ const result = await parseWhere({ status: { $in: ['draft', 'published'] } }, testCollection);
931
+ expect(result.status).toBeUndefined();
932
+ expect(result.filters).toEqual([
933
+ { kind: 'docColumn', column: 'status', operator: '$in', value: ['draft', 'published'] },
934
+ ]);
935
+ });
936
+ it('moves a multi-operator top-level path to document-column filters', async () => {
937
+ const single = await parseWhere({ path: { $contains: 'docs' } }, testCollection);
938
+ expect(single.pathFilter).toEqual({ operator: '$contains', value: 'docs' });
939
+ const multi = await parseWhere({ path: { $contains: 'docs', $ne: 'docs/old' } }, testCollection);
940
+ expect(multi.pathFilter).toBeUndefined();
941
+ expect(multi.filters).toEqual([
942
+ { kind: 'docColumn', column: 'path', operator: '$contains', value: 'docs' },
943
+ { kind: 'docColumn', column: 'path', operator: '$ne', value: 'docs/old' },
944
+ ]);
945
+ });
946
+ it('emits every operator inside a relation sub-where', async () => {
947
+ const result = await parseWhere({ category: { slug: { $contains: 'new', $ne: 'news-old' } } }, testCollection, ctx);
948
+ expect(result.filters[0]).toMatchObject({
949
+ kind: 'relation',
950
+ fieldName: 'category',
951
+ nested: [
952
+ expect.objectContaining({ fieldName: 'slug', operator: '$contains', value: 'new' }),
953
+ expect.objectContaining({ fieldName: 'slug', operator: '$ne', value: 'news-old' }),
954
+ ],
955
+ });
956
+ });
957
+ it('emits every operator inside a quantified relation filter', async () => {
958
+ const result = await parseWhere({ tags: { $every: { slug: { $contains: 'a', $ne: 'b' } } } }, testCollection, ctx);
959
+ expect(result.filters[0]).toMatchObject({
960
+ kind: 'relation',
961
+ quantifier: 'every',
962
+ nested: [
963
+ expect.objectContaining({ operator: '$contains', value: 'a' }),
964
+ expect.objectContaining({ operator: '$ne', value: 'b' }),
965
+ ],
966
+ });
967
+ });
968
+ it.each([
969
+ [{ views: {} }, "operator object for 'views' is empty"],
970
+ [{ views: { $gte: undefined } }, "operator object for 'views' is empty"],
971
+ [{ views: { $gte: 10, $bogus: 1 } }, "unknown operator '$bogus' for 'views'"],
972
+ [{ views: { $gte: 10, $bogus: undefined } }, "unknown operator '$bogus' for 'views'"],
973
+ [{ status: { $wat: 'x' } }, "unknown operator '$wat' for 'status'"],
974
+ [{ id: {} }, "operator object for 'id' is empty"],
975
+ [{ views: [1, 2] }, "invalid value for 'views'"],
976
+ [{ category: {} }, "operator object for 'category' is empty"],
977
+ ])('rejects %o', async (where, message) => {
978
+ await expect(parseWhere(where, testCollection, ctx)).rejects.toThrow(message);
979
+ });
980
+ it('accepts multi-operator objects in strict security predicates', async () => {
981
+ const filters = await parsePredicateFilters({ views: { $gte: 10, $lte: 20 }, status: { $ne: 'archived', $in: ['published'] } }, testCollection, ctx, { strict: true });
982
+ expect(filters).toEqual([
983
+ views('$gte', 10),
984
+ views('$lte', 20),
985
+ { kind: 'docColumn', column: 'status', operator: '$ne', value: 'archived' },
986
+ { kind: 'docColumn', column: 'status', operator: '$in', value: ['published'] },
987
+ ]);
988
+ });
989
+ it.each([
990
+ [{ views: { $gte: 10, $wat: 1 } }, 'supported operators'],
991
+ [{ status: { $eq: 'published', $contains: 'pub' } }, "'$contains' is not supported"],
992
+ [{ status: { $ne: 'archived', $in: 'published' } }, 'requires an array'],
993
+ ])('validates every operator of strict predicate %o', async (predicate, message) => {
994
+ await expect(parsePredicateFilters(predicate, testCollection, ctx, { strict: true })).rejects.toThrow(message);
995
+ });
996
+ });
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@byline/core",
3
3
  "private": false,
4
4
  "license": "MPL-2.0",
5
- "version": "6.10.0",
5
+ "version": "6.11.0",
6
6
  "engines": {
7
7
  "node": ">=22.22.2"
8
8
  },
@@ -82,7 +82,7 @@
82
82
  "dist"
83
83
  ],
84
84
  "dependencies": {
85
- "@byline/auth": "6.10.0",
85
+ "@byline/auth": "6.11.0",
86
86
  "dotenv": "^18.0.8",
87
87
  "pino": "^10.4.0",
88
88
  "sharp": "^0.35.5",