@byline/core 2.5.1 → 2.5.2

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.
@@ -120,23 +120,35 @@ export interface CombinatorFilter {
120
120
  children: DocumentFilter[];
121
121
  }
122
122
  /**
123
- * A predicate over a document-version column (`status`, `path`). Distinct
124
- * from `FieldFilter` — these columns live on `document_versions` itself,
125
- * not on the EAV stores, so they compile to a direct outer-scope column
126
- * comparison rather than an `EXISTS` subquery.
123
+ * A predicate over a document-version column (`status`, `path`, `id`).
124
+ * Distinct from `FieldFilter` — these columns live on `document_versions`
125
+ * itself (or the current-documents view), not on the EAV stores, so they
126
+ * compile to a direct outer-scope column comparison rather than an `EXISTS`
127
+ * subquery.
127
128
  *
128
- * Produced by `parse-where` when `status` / `path` appear *inside* a
129
- * combinator (`$or` / `$and` child). At the top level the same keys are
130
- * intercepted as reserved keys on `ParsedWhere.status` / `ParsedWhere.pathFilter`
131
- * because they map to direct adapter parameters there; inside a
132
- * combinator that mapping no longer makes sense (you can't OR-combine
133
- * with the outer scalar parameter), so they downshift to this filter.
129
+ * Produced by `parse-where` for two reasons:
130
+ *
131
+ * - `status` / `path` appearing *inside* a combinator (`$or` / `$and`
132
+ * child) or inside a relation sub-clause. At the top level the same
133
+ * keys are intercepted as `ParsedWhere.status` / `ParsedWhere.pathFilter`
134
+ * because they map to direct adapter parameters there; inside a
135
+ * combinator that mapping no longer makes sense (you can't OR-combine
136
+ * with the outer scalar parameter), so they downshift to this filter.
137
+ *
138
+ * - `id` at *any* scope. Unlike `status` (single equality, used in many
139
+ * non-filter call sites) and `path` (needs the `pathProjection` join
140
+ * against `byline_document_paths`), `id` is a plain column on the
141
+ * current-documents view comparable directly at every scope. Skipping
142
+ * a top-level scalar form keeps the surface area small.
143
+ *
144
+ * `value` is widened beyond `string | null` so the `$in` / `$nin` operators
145
+ * (the headline use case for `id`) can carry array operands.
134
146
  */
135
147
  export interface DocumentColumnFilter {
136
148
  kind: 'docColumn';
137
- column: 'status' | 'path';
149
+ column: 'status' | 'path' | 'id';
138
150
  operator: FieldFilterOperator;
139
- value: string | null;
151
+ value: string | number | boolean | null | Array<string | number>;
140
152
  }
141
153
  /**
142
154
  * Any filter that can appear in a `findDocuments` call — a direct field
@@ -10,7 +10,7 @@ import { fieldTypeToStore } from '../storage/field-store-map.js';
10
10
  // Document-level reserved keys
11
11
  // ---------------------------------------------------------------------------
12
12
  /** Where clause keys that map to document-level columns, not EAV stores. */
13
- const DOCUMENT_LEVEL_KEYS = new Set(['status', 'path', 'query']);
13
+ const DOCUMENT_LEVEL_KEYS = new Set(['status', 'path', 'query', 'id']);
14
14
  // ---------------------------------------------------------------------------
15
15
  // Document-level sort columns
16
16
  // ---------------------------------------------------------------------------
@@ -225,6 +225,26 @@ async function parseWhereInternal(where, definition, ctx, { isNested, inCombinat
225
225
  }
226
226
  continue;
227
227
  }
228
+ // `id` always downshifts to a `DocumentColumnFilter` — no top-level
229
+ // scalar form. Unlike `status` (single equality, used in many non-filter
230
+ // call sites) and `path` (needs the `pathProjection` join), `id` is a
231
+ // plain column on the current-documents view comparable directly at any
232
+ // scope. Unifying all three scopes through the same downshift keeps the
233
+ // surface area small. Note: the value is passed through verbatim — we
234
+ // don't `String(...)` it the way status/path do, because the headline
235
+ // use case is `$in` / `$nin` carrying an array of document ids.
236
+ if (key === 'id') {
237
+ const parsed = normaliseToOperator(rawValue);
238
+ if (parsed) {
239
+ result.filters.push({
240
+ kind: 'docColumn',
241
+ column: 'id',
242
+ operator: parsed.operator,
243
+ value: parsed.value,
244
+ });
245
+ }
246
+ continue;
247
+ }
228
248
  // --- Field-level keys --------------------------------------------------
229
249
  const field = definition.fields.find((f) => f.name === key);
230
250
  if (!field)
@@ -625,6 +625,65 @@ describe('parseWhere — combinators', () => {
625
625
  });
626
626
  });
627
627
  // ---------------------------------------------------------------------------
628
+ // parseWhere — `id` reserved key
629
+ // ---------------------------------------------------------------------------
630
+ //
631
+ // `id` is the logical document id (`document_id` on the current-documents
632
+ // view). It is reserved at every scope and always downshifts to a
633
+ // `DocumentColumnFilter` — no top-level scalar form like `status` /
634
+ // `pathFilter`, because `id` is a plain column comparable directly.
635
+ describe('parseWhere — `id` reserved key', () => {
636
+ it('emits a docColumn filter for a bare top-level `id` value', async () => {
637
+ const result = await parseWhere({ id: 'doc-123' }, testCollection);
638
+ expect(result.filters).toEqual([
639
+ { kind: 'docColumn', column: 'id', operator: '$eq', value: 'doc-123' },
640
+ ]);
641
+ });
642
+ it('emits a docColumn filter with array value for top-level `id: { $in }`', async () => {
643
+ // The headline use case — batch lookup by a list of document ids.
644
+ // Value passes through verbatim (no `String(...)` coercion) so the
645
+ // array survives to the adapter's `$in` SQL builder.
646
+ const ids = ['doc-1', 'doc-2', 'doc-3'];
647
+ const result = await parseWhere({ id: { $in: ids } }, testCollection);
648
+ expect(result.filters).toEqual([
649
+ { kind: 'docColumn', column: 'id', operator: '$in', value: ids },
650
+ ]);
651
+ });
652
+ it('emits a docColumn filter for `id` inside an $or combinator', async () => {
653
+ const result = await parseWhere({ $or: [{ id: 'doc-a' }, { id: 'doc-b' }] }, testCollection);
654
+ expect(result.filters).toHaveLength(1);
655
+ expect(result.filters[0]).toMatchObject({
656
+ kind: 'or',
657
+ children: [
658
+ { kind: 'docColumn', column: 'id', operator: '$eq', value: 'doc-a' },
659
+ { kind: 'docColumn', column: 'id', operator: '$eq', value: 'doc-b' },
660
+ ],
661
+ });
662
+ });
663
+ it('emits a docColumn filter for `id` inside a nested relation sub-where', async () => {
664
+ // Reserved-key precedence — same as `status` / `path`: inside a
665
+ // relation hop, `id` refers to the target version's document_id, not
666
+ // a field of the same name.
667
+ const result = await parseWhere({ category: { id: 'cat-news' } }, testCollection, ctx);
668
+ expect(result.filters).toHaveLength(1);
669
+ expect(result.filters[0]).toEqual({
670
+ kind: 'relation',
671
+ fieldName: 'category',
672
+ targetCollectionId: 'id-test-categories',
673
+ nested: [{ kind: 'docColumn', column: 'id', operator: '$eq', value: 'cat-news' }],
674
+ });
675
+ });
676
+ it('does NOT populate any top-level scalar slot for `id`', async () => {
677
+ // Sanity: unlike status / pathFilter, `id` has no top-level scalar
678
+ // form on `ParsedWhere`. Always lives in `filters[]` as a docColumn.
679
+ const result = await parseWhere({ id: 'doc-x' }, testCollection);
680
+ expect(result.status).toBeUndefined();
681
+ expect(result.pathFilter).toBeUndefined();
682
+ expect(result.query).toBeUndefined();
683
+ expect(result.filters).toHaveLength(1);
684
+ });
685
+ });
686
+ // ---------------------------------------------------------------------------
628
687
  // mergePredicates
629
688
  // ---------------------------------------------------------------------------
630
689
  describe('mergePredicates', () => {
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": "2.5.1",
5
+ "version": "2.5.2",
6
6
  "engines": {
7
7
  "node": ">=20.9.0"
8
8
  },
@@ -79,7 +79,7 @@
79
79
  "sharp": "^0.34.5",
80
80
  "uuid": "^14.0.0",
81
81
  "zod": "^4.4.3",
82
- "@byline/auth": "2.5.1"
82
+ "@byline/auth": "2.5.2"
83
83
  },
84
84
  "devDependencies": {
85
85
  "@biomejs/biome": "2.4.15",