Skip to content

Commit 73110de

Browse files
committed
fix(protocol): treat optional array fields as Path leaves; export predicate types
`Path<T>` synthesized bogus dotted-path keys for OPTIONAL array fields. The leaf test in `_AllPaths` ran on the raw `T[K]`, which for `tags?: string[]` is `string[] | undefined`; the `undefined` arm defeats the `ReadonlyArray` leaf check in `_IsPathLeaf` (a union does not extend `ReadonlyArray`), so the walker descended into `Array.prototype` and emitted `` `tags.map.${string}` `` typed `undefined`. Required arrays were unaffected. That synthetic `undefined`-typed path broke structural assignability of a bound `Db<Config>`/`BaerlyClient<Config>` to a hand-rolled interface whose `collection(name: string)` takes a runtime string: the generic collapses the row to the union of every row, and the bogus path made any index-signature predicate object non-assignable — so registering one unrelated collection with an optional array field broke `.where()` typing everywhere. Run the leaf test on `NonNullable<T[K]>` so an optional array terminates path recursion exactly like a required one. `Path<{ tags?: string[] }>` is now `"tags"`, with no `Array.prototype` members. Also export the types consumers needed to avoid hand-rolling a structural shim in the first place: - `Predicate` / `PredicateArg` from `@gusto/baerly-storage` - `PredicateArg` from `@gusto/baerly-storage/client` (which already exported `ClientCollection` / `Predicate`). The client handle type stays `ClientCollection`; the in-process `Collection` is exported only from the root entry, since the client's HTTP handle is structurally distinct (read-only `.where(...)`, `TerminalOptions`). Adds a `Path` regression assertion in collection-api.test-d.ts. Both changes are additive and type-only; verify:agent, bundle-sizes (all 18 under budget), and the default test suite are green.
1 parent 3ae2ddc commit 73110de

6 files changed

Lines changed: 97 additions & 2 deletions

File tree

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
---
2+
"@gusto/baerly-storage": minor
3+
---
4+
5+
Fix `Path<T>` walking `Array.prototype` for optional array fields; export
6+
`PredicateArg` / `Predicate` / `Collection` for consumers
7+
8+
**Bug fix — optional array fields no longer poison predicate types**
9+
10+
`Path<T>` (the dotted-path key type behind `Predicate<T>` / `.where(...)`)
11+
descended into `Array.prototype` when a field was an **optional** array. For a
12+
field `tags?: string[]`, the field type is `string[] | undefined`, and the
13+
`undefined` arm defeated the array-is-a-leaf check — so the path walker
14+
recursed into the array's methods and synthesized bogus keys like
15+
`` `tags.map.${string}` `` typed `undefined`.
16+
17+
Required array fields (`tags: string[]`) were unaffected; only the optional
18+
form triggered it.
19+
20+
The visible symptom: registering **any** collection with an optional array
21+
field (e.g. a Zod `z.array(z.string()).optional()`) broke structural
22+
assignability of a bound `Db<Config>` / `BaerlyClient<Config>` to a hand-rolled
23+
interface whose `collection(name: string)` takes a runtime string — because the
24+
generic collapses the row to the union of every row, and the synthetic
25+
`undefined`-typed prototype path made every index-signature predicate object
26+
non-assignable. Collections that never touched the array field were still
27+
affected.
28+
29+
The fix runs the leaf test on `NonNullable<T[K]>`, so an optional array
30+
terminates path recursion exactly like a required one. `Path<{ tags?: string[]
31+
}>` is now `"tags"`, with no `Array.prototype` members.
32+
33+
**New public exports**
34+
35+
- `PredicateArg` and `Predicate` are now exported from `@gusto/baerly-storage`.
36+
- `PredicateArg` is now exported from `@gusto/baerly-storage/client` (which
37+
already exported `ClientCollection` and `Predicate`). The client handle type
38+
remains `ClientCollection`; the in-process `Collection` type is exported only
39+
from the root `@gusto/baerly-storage` entry, since the client's HTTP handle
40+
is structurally distinct (read-only `.where(...)`, `TerminalOptions`).
41+
42+
These let consumers name the `.where(...)` argument and predicate types
43+
directly instead of hand-rolling a structural interface to accept a `Db` or a
44+
`BaerlyClient`.
45+
46+
**Migration**
47+
48+
- No action required. Both changes are additive; existing typed call sites are
49+
unaffected.
50+
- If you hand-rolled a structural `collection(name: string)` shim to read
51+
collections by a runtime-computed name, you can now either import the real
52+
types, or narrow at the boundary with
53+
`(db as Db<UnboundConfig>).collection(name)` to open the row to
54+
`DocumentData` for dynamic names.

packages/client/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,5 @@ export type {
1414
LogEntry,
1515
OrderSpec,
1616
Predicate,
17+
PredicateArg,
1718
} from "@baerly/protocol";

packages/protocol/src/collection-api.test-d.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
* from any barrel — exporting the assertion handles is harmless.
2020
*/
2121

22-
import { type Predicate } from "./collection-api.ts";
22+
import { type Path, type Predicate } from "./collection-api.ts";
2323
import type { PredicateArg, PredicateBuilder } from "./query/builder.ts";
2424
import type { DocumentData } from "./json.ts";
2525

@@ -187,3 +187,28 @@ export const _depth6Rejected: Predicate<DeepDoc> = {
187187

188188
export const _builderReturnsSelf = (q: PredicateBuilder<Ticket>): PredicateBuilder<Ticket> =>
189189
q.eq("status", "open").gt("count", 5);
190+
191+
// --- Optional array/nested fields are Path leaves (regression) -----
192+
// An OPTIONAL array field must terminate `Path` recursion the same as a
193+
// required one. The leaf test in `_AllPaths` runs on `NonNullable<T[K]>`;
194+
// without that guard, `T["tags"]` was `string[] | undefined`, whose
195+
// `undefined` arm defeated the `ReadonlyArray` leaf check, so `_AllPaths`
196+
// descended into `Array.prototype` and synthesized `tags.map.${string}:
197+
// undefined`. That bogus path broke structural assignability for consumers
198+
// whose predicate parameter is an index signature (e.g. a hand-rolled
199+
// `collection(name: string)` shim over a config whose row union contains an
200+
// optional array field). See the matching changeset.
201+
type OptArrayDoc = DocumentData & { kind: string; tags?: string[] };
202+
203+
// The optional array field itself is the only path it contributes.
204+
export const _optArrayIsLeafPath: Path<OptArrayDoc> = "tags";
205+
export const _optArraySiblingPath: Path<OptArrayDoc> = "kind";
206+
207+
// No `Array.prototype` members leak into `Path<T>`.
208+
// @ts-expect-error — `tags.map` is Array.prototype, not a document path.
209+
export const _noArrayPrototypeMap: Path<OptArrayDoc> = "tags.map";
210+
// @ts-expect-error — nor any deeper synthetic prototype path.
211+
export const _noArrayPrototypeDeep: Path<OptArrayDoc> = "tags.map.length";
212+
213+
// Predicate-level: the optional array remains a queryable leaf value.
214+
export const _optArrayLeafValue: Predicate<OptArrayDoc> = { tags: ["x"] };

packages/protocol/src/collection-api.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,12 @@ type _AllPaths<T, D extends _PathDepth[number] = 4> = [D] extends [never]
220220
? keyof _StripIndex<T> & string extends never
221221
? string
222222
: {
223-
[K in keyof _StripIndex<T> & string]: _IsPathLeaf<T[K]> extends true
223+
// Leaf test runs on `NonNullable<T[K]>`: for an optional field the raw
224+
// `T[K]` is `V | undefined`, and the `undefined` arm defeats the
225+
// array-is-a-leaf check in `_IsPathLeaf` (a union doesn't extend
226+
// `ReadonlyArray`), which would otherwise make an optional `string[]`
227+
// descend into `Array.prototype` and synthesize `foo.map.${string}`.
228+
[K in keyof _StripIndex<T> & string]: _IsPathLeaf<NonNullable<T[K]>> extends true
224229
? K
225230
: K | `${K}.${_AllPaths<NonNullable<T[K]>, _PathDepth[D]>}`;
226231
}[keyof _StripIndex<T> & string]

packages/server/API.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ import {
3434
MemoryStorage, // in-memory `Storage`; canonical for tests
3535
type Collection,
3636
type Query,
37+
type Predicate, // equality-object `.where(...)` form
38+
type PredicateArg, // `.where(...)` arg: object or builder callback
3739
type DocumentData,
3840
type RowOf,
3941
type CollectionNames, // row-shape inference from a bound config
@@ -59,6 +61,8 @@ import {
5961
type ClientQuery,
6062
type TerminalOptions,
6163
type Fetcher,
64+
type Predicate,
65+
type PredicateArg,
6266
} from "@gusto/baerly-storage/client";
6367

6468
// Auth verifiers

packages/server/src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@ export { type SchemaIssue, type SchemaValidator, validateOrThrow } from "./schem
5252
* - {@link Query} / {@link Collection}: the locked predicate-AST
5353
* interfaces returned by `Db.collection(...)`. Consumers that
5454
* destructure the chain need the named types.
55+
* - {@link Predicate} / {@link PredicateArg}: the equality-object and
56+
* `.where(...)`-argument shapes. Consumers writing a helper that
57+
* forwards a predicate — or naming a collection handle in a shared
58+
* interface — need these instead of hand-rolling a structural shim.
5559
* - {@link Storage} + its result types
5660
* ({@link StorageGetResult}, {@link StorageListEntry},
5761
* {@link StoragePutResult}): the interface every storage adapter
@@ -74,6 +78,8 @@ export {
7478
type StorageListEntry,
7579
type StoragePutResult,
7680
type Collection,
81+
type Predicate,
82+
type PredicateArg,
7783
type Verifier,
7884
type VerifierResult,
7985
} from "@baerly/protocol";

0 commit comments

Comments
 (0)