indexwright 0.2.0 → 0.3.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.
package/CHANGELOG.md CHANGED
@@ -5,16 +5,63 @@ All notable changes to this project are documented here. The format follows
5
5
  versioning. Pre-1.0, rule additions and message changes may land in minor releases; the `json`
6
6
  output shape is the stable contract.
7
7
 
8
+ ## [0.3.0] — 2026-09-21
9
+
10
+ The other half of the file. `fieldOverrides` — single-field index configuration — was typed
11
+ `unknown[]` and read by nothing; it is now validated to the same depth as `indexes` and reduced to
12
+ a canonical form, so that `@indexwright/record` 0.7.0 can reconcile a declaration against a live
13
+ field listing on the one key the linter will report on. No rule reads it yet, and nothing about the
14
+ linter's output or exit codes changes. The package also moves inside the repository, to
15
+ `packages/indexwright`, without moving anything about what is published.
16
+
17
+ ### Added
18
+
19
+ - **`fieldOverrides` is validated and has a canonical form** (issues #53 and #54). The file's other
20
+ half was typed `unknown[]` and read by nothing. It is now held to the same depth as `indexes` —
21
+ an entry needs `collectionGroup`, `fieldPath`, and an `indexes` array (empty for an exemption),
22
+ each index exactly one of `order`, `arrayConfig`, and `vectorConfig` (and a `queryScope`, read as
23
+ `COLLECTION` when omitted, as the Firebase CLI does), and `ttl` a boolean when present — and `analyseOverrides` reduces each to an `AnalysedOverride` keyed as
24
+ `<collectionGroup>::<fieldPath>::<queryScope>:<direction>|…`, sorted, with entries alike in scope and
25
+ direction collapsed, as the set it is. No rule reads it yet; the form exists so that `@indexwright/record` can reconcile a
26
+ declaration against a live field listing on the same key the linter will report on, rather than
27
+ on a second model. A file whose `fieldOverrides` cannot be read that way is now malformed, where
28
+ it previously passed unexamined. `fieldDirection` accepts any object carrying the three configs,
29
+ which every `IndexField` still is.
30
+
31
+ ### Changed
32
+
33
+ - `IndexDocument.fieldOverrides` is typed `FieldOverride[]`, no longer `unknown[]`. A consumer
34
+ that built one by hand with entries of another shape stops compiling.
35
+ - The package now lives at `packages/indexwright` rather than at the repository root, so that
36
+ `@indexwright/record` can depend on it from the working tree instead of from a published release.
37
+ Nothing about the published package moves: same name, same bin, same `exports`, same
38
+ dependency-free guarantee.
39
+ - `SPEC.md` is no longer shipped inside the tarball. It specifies both packages, so it belongs to
40
+ the repository rather than to either one, and `files` cannot reach above a package directory. The
41
+ alternative — copying it in at pack time — runs through `prepublishOnly`, which npm skips entirely
42
+ under `ignore-scripts=true`; that would make the verified tarball and the published one differ in
43
+ exactly the way `verify-package` exists to catch. The spec is linked from the README and is in the
44
+ repository.
45
+
46
+ ### Notes
47
+
48
+ - **The override key carries no marker telling it from an index key.** `posts::tags::COLLECTION:…`
49
+ and `posts::COLLECTION::tags:…` are the same shape read by different rules, and a reader that
50
+ does not know which listing a key came from cannot tell them apart by the string alone. Decided
51
+ and left as it is for this release: no rule reports an override key yet and the `json` output
52
+ does not carry one, so adding a marker later would change nothing that is a contract today, while
53
+ adding one now would fix a spelling nothing has needed.
54
+
8
55
  ## [0.2.0] — 2026-08-10
9
56
 
10
57
  ### Added
11
58
 
12
- - A query corpus format, specified in [SPEC.md](SPEC.md) §7 and versioned by its own
59
+ - A query corpus format, specified in [SPEC.md](https://github.com/uny/indexwright/blob/main/SPEC.md) §7 and versioned by its own
13
60
  `corpusVersion` rather than by any package's release number. It is the contract between capture
14
61
  and the coverage check, and the first indexwright artefact meant to be committed and reviewed in
15
62
  a diff alongside `firestore.indexes.json`.
16
63
  - `@indexwright/record`, a separate package that writes one. See
17
- [its changelog](packages/record/CHANGELOG.md).
64
+ [its changelog](https://github.com/uny/indexwright/blob/main/packages/record/CHANGELOG.md).
18
65
  - `indexwright record` now says that the verb ships in `@indexwright/record` and how to run it,
19
66
  instead of reporting an unknown command. The cost of splitting the family into two packages is a
20
67
  second package to discover, and "unknown command" reads as "indexwright cannot do this".
@@ -53,6 +100,7 @@ First release. Static analysis of `firestore.indexes.json`; no network access, n
53
100
  `__name__` to the same resource.
54
101
  - A provisional JavaScript API, so the rules can run without spawning a process.
55
102
 
103
+ [0.3.0]: https://github.com/uny/indexwright/releases/tag/v0.3.0
56
104
  [0.2.0]: https://github.com/uny/indexwright/releases/tag/v0.2.0
57
105
  [0.1.1]: https://github.com/uny/indexwright/releases/tag/v0.1.1
58
106
  [0.1.0]: https://github.com/uny/indexwright/releases/tag/v0.1.0
package/README.md CHANGED
@@ -88,7 +88,7 @@ measured your own noise level.
88
88
  | `quota-headroom` | The declared index count is close to the per-database limit. |
89
89
 
90
90
  Each rule's rationale, its known false positives, and the exact detection condition are in
91
- [SPEC.md](SPEC.md) §5.
91
+ [SPEC.md](https://github.com/uny/indexwright/blob/main/SPEC.md) §5.
92
92
 
93
93
  ### A note on `__name__`
94
94
 
@@ -147,7 +147,7 @@ console.log(result.summary.byRule);
147
147
  no Firestore connection. Answering *is this index needed* takes the queries, which is a separate
148
148
  package:
149
149
 
150
- - **v0.2 — query capture, shipped.** [`@indexwright/record`](packages/record) runs a test suite
150
+ - **v0.2 — query capture, shipped.** [`@indexwright/record`](https://github.com/uny/indexwright/tree/main/packages/record) runs a test suite
151
151
  with `FIRESTORE_EMULATOR_HOST` pointed at a pass-through proxy and records the `StructuredQuery`
152
152
  shapes it observes as a corpus. This is language- and framework-independent because it operates
153
153
  on the wire protocol rather than on source code.
@@ -158,7 +158,7 @@ package:
158
158
  Coverage is bounded by what actually exercises the proxy. A query no test issues is not observed,
159
159
  and absence of observation is not evidence that an index is unused. That limit is inherent; the
160
160
  narrower ones that are not — the Firebase Web SDK's transport, and snapshot listeners — are
161
- [named in the spec](SPEC.md) and counted in the corpus rather than passed over.
161
+ [named in the spec](https://github.com/uny/indexwright/blob/main/SPEC.md) and counted in the corpus rather than passed over.
162
162
 
163
163
  ## Toward 1.0
164
164
 
@@ -170,8 +170,8 @@ Until then the version stays below 1.0 and the rules stay provisional.
170
170
 
171
171
  ## Development
172
172
 
173
- This is an npm workspace holding two packages: `indexwright` at the root and
174
- [`@indexwright/record`](packages/record) under `packages/`. Both scripts cover both.
173
+ This is an npm workspace holding two packages under `packages/`: `indexwright` and
174
+ [`@indexwright/record`](https://github.com/uny/indexwright/tree/main/packages/record). Both scripts cover both.
175
175
 
176
176
  ```bash
177
177
  npm install
package/dist/index.d.ts CHANGED
@@ -8,11 +8,11 @@
8
8
  export { formatGithub, type GithubOutput } from './format/github.js';
9
9
  export { formatJson } from './format/json.js';
10
10
  export { formatText } from './format/text.js';
11
- export { analyse, canonicalFields, fieldDirection, implicitNameDirection, indexKey, NAME_FIELD, } from './key.js';
11
+ export { analyse, analyseOverrides, canonicalFields, canonicalSingleFieldIndexes, fieldDirection, formatSingleFieldIndex, implicitNameDirection, indexKey, NAME_FIELD, overrideKey, type IndexConfig, } from './key.js';
12
12
  export { DEFAULT_QUOTA, DEFAULT_QUOTA_THRESHOLD, lintDocuments, lintFiles, lintTexts, type DocumentInput, type LintOptions, type TextInput, } from './lint.js';
13
13
  export { MalformedInputError, parseDocument, validateDocument } from './parse.js';
14
14
  export { getRule, isRuleId, rules } from './rules/index.js';
15
15
  export { RULE_IDS } from './types.js';
16
- export type { AnalysedIndex, CanonicalField, CompositeIndex, Finding, IndexDocument, IndexField, LintError, LintResult, LintSummary, OutputFormat, Rule, RuleContext, RuleId, RuleOptions, } from './types.js';
16
+ export type { AnalysedIndex, AnalysedOverride, CanonicalField, CanonicalSingleFieldIndex, CompositeIndex, FieldOverride, Finding, IndexDocument, IndexField, LintError, LintResult, LintSummary, OutputFormat, Rule, RuleContext, RuleId, RuleOptions, SingleFieldIndex, } from './types.js';
17
17
  export { VERSION } from './version.js';
18
18
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,EAAE,YAAY,EAAE,KAAK,YAAY,EAAE,MAAM,oBAAoB,CAAC;AACrE,OAAO,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAC9C,OAAO,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAC9C,OAAO,EACL,OAAO,EACP,eAAe,EACf,cAAc,EACd,qBAAqB,EACrB,QAAQ,EACR,UAAU,GACX,MAAM,UAAU,CAAC;AAClB,OAAO,EACL,aAAa,EACb,uBAAuB,EACvB,aAAa,EACb,SAAS,EACT,SAAS,EACT,KAAK,aAAa,EAClB,KAAK,WAAW,EAChB,KAAK,SAAS,GACf,MAAM,WAAW,CAAC;AACnB,OAAO,EAAE,mBAAmB,EAAE,aAAa,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAClF,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,KAAK,EAAE,MAAM,kBAAkB,CAAC;AAC5D,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AACtC,YAAY,EACV,aAAa,EACb,cAAc,EACd,cAAc,EACd,OAAO,EACP,aAAa,EACb,UAAU,EACV,SAAS,EACT,UAAU,EACV,WAAW,EACX,YAAY,EACZ,IAAI,EACJ,WAAW,EACX,MAAM,EACN,WAAW,GACZ,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,EAAE,YAAY,EAAE,KAAK,YAAY,EAAE,MAAM,oBAAoB,CAAC;AACrE,OAAO,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAC9C,OAAO,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAC9C,OAAO,EACL,OAAO,EACP,gBAAgB,EAChB,eAAe,EACf,2BAA2B,EAC3B,cAAc,EACd,sBAAsB,EACtB,qBAAqB,EACrB,QAAQ,EACR,UAAU,EACV,WAAW,EACX,KAAK,WAAW,GACjB,MAAM,UAAU,CAAC;AAClB,OAAO,EACL,aAAa,EACb,uBAAuB,EACvB,aAAa,EACb,SAAS,EACT,SAAS,EACT,KAAK,aAAa,EAClB,KAAK,WAAW,EAChB,KAAK,SAAS,GACf,MAAM,WAAW,CAAC;AACnB,OAAO,EAAE,mBAAmB,EAAE,aAAa,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAClF,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,KAAK,EAAE,MAAM,kBAAkB,CAAC;AAC5D,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AACtC,YAAY,EACV,aAAa,EACb,gBAAgB,EAChB,cAAc,EACd,yBAAyB,EACzB,cAAc,EACd,aAAa,EACb,OAAO,EACP,aAAa,EACb,UAAU,EACV,SAAS,EACT,UAAU,EACV,WAAW,EACX,YAAY,EACZ,IAAI,EACJ,WAAW,EACX,MAAM,EACN,WAAW,EACX,gBAAgB,GACjB,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC"}
package/dist/index.js CHANGED
@@ -8,7 +8,7 @@
8
8
  export { formatGithub } from './format/github.js';
9
9
  export { formatJson } from './format/json.js';
10
10
  export { formatText } from './format/text.js';
11
- export { analyse, canonicalFields, fieldDirection, implicitNameDirection, indexKey, NAME_FIELD, } from './key.js';
11
+ export { analyse, analyseOverrides, canonicalFields, canonicalSingleFieldIndexes, fieldDirection, formatSingleFieldIndex, implicitNameDirection, indexKey, NAME_FIELD, overrideKey, } from './key.js';
12
12
  export { DEFAULT_QUOTA, DEFAULT_QUOTA_THRESHOLD, lintDocuments, lintFiles, lintTexts, } from './lint.js';
13
13
  export { MalformedInputError, parseDocument, validateDocument } from './parse.js';
14
14
  export { getRule, isRuleId, rules } from './rules/index.js';
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,EAAE,YAAY,EAAqB,MAAM,oBAAoB,CAAC;AACrE,OAAO,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAC9C,OAAO,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAC9C,OAAO,EACL,OAAO,EACP,eAAe,EACf,cAAc,EACd,qBAAqB,EACrB,QAAQ,EACR,UAAU,GACX,MAAM,UAAU,CAAC;AAClB,OAAO,EACL,aAAa,EACb,uBAAuB,EACvB,aAAa,EACb,SAAS,EACT,SAAS,GAIV,MAAM,WAAW,CAAC;AACnB,OAAO,EAAE,mBAAmB,EAAE,aAAa,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAClF,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,KAAK,EAAE,MAAM,kBAAkB,CAAC;AAC5D,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAiBtC,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,EAAE,YAAY,EAAqB,MAAM,oBAAoB,CAAC;AACrE,OAAO,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAC9C,OAAO,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAC9C,OAAO,EACL,OAAO,EACP,gBAAgB,EAChB,eAAe,EACf,2BAA2B,EAC3B,cAAc,EACd,sBAAsB,EACtB,qBAAqB,EACrB,QAAQ,EACR,UAAU,EACV,WAAW,GAEZ,MAAM,UAAU,CAAC;AAClB,OAAO,EACL,aAAa,EACb,uBAAuB,EACvB,aAAa,EACb,SAAS,EACT,SAAS,GAIV,MAAM,WAAW,CAAC;AACnB,OAAO,EAAE,mBAAmB,EAAE,aAAa,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAClF,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,KAAK,EAAE,MAAM,kBAAkB,CAAC;AAC5D,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAqBtC,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC"}
package/dist/key.d.ts CHANGED
@@ -1,12 +1,14 @@
1
- import type { AnalysedIndex, CanonicalField, IndexDocument, IndexField } from './types.js';
1
+ import type { AnalysedIndex, AnalysedOverride, CanonicalField, CanonicalSingleFieldIndex, IndexDocument, IndexField, SingleFieldIndex } from './types.js';
2
2
  export declare const NAME_FIELD = "__name__";
3
+ /** The three configs a field can carry, which a composite index's field and a single-field index share. */
4
+ export type IndexConfig = Pick<IndexField, 'order' | 'arrayConfig' | 'vectorConfig'>;
3
5
  /**
4
6
  * The direction a field contributes to the canonical key.
5
7
  *
6
8
  * `parse.ts` guarantees exactly one of `order`, `arrayConfig`, and `vectorConfig` is present, so
7
9
  * the fallback is unreachable for validated input.
8
10
  */
9
- export declare function fieldDirection(field: IndexField): string;
11
+ export declare function fieldDirection(field: IndexConfig): string;
10
12
  /**
11
13
  * The direction Firestore would give the document key it appends to this field list.
12
14
  *
@@ -32,4 +34,30 @@ export declare function formatField(field: CanonicalField): string;
32
34
  export declare function indexKey(collectionGroup: string, queryScope: string, fields: readonly CanonicalField[]): string;
33
35
  /** Precompute the canonical form once so every rule shares one interpretation of the document. */
34
36
  export declare function analyse(document: IndexDocument): AnalysedIndex[];
37
+ export declare function formatSingleFieldIndex(index: CanonicalSingleFieldIndex): string;
38
+ /**
39
+ * The canonical key of a field override (SPEC §5, *Canonical override key*).
40
+ *
41
+ * The same three-part shape as `indexKey`, with the field path where the query scope sits and the
42
+ * scope inside each entry — a single-field index names one field, so what varies per entry is the
43
+ * scope. `indexes` must already be in canonical order; the key is a rendering, not a sort.
44
+ */
45
+ export declare function overrideKey(collectionGroup: string, fieldPath: string, indexes: readonly CanonicalSingleFieldIndex[]): string;
46
+ /**
47
+ * Reduce an override's declared set to canonical form.
48
+ *
49
+ * A composite index's fields are a sequence and their order is part of the key; an override's
50
+ * indexes are a set, and Firestore holds at most one single-field index per (scope, direction) of
51
+ * a field. So the entries are sorted, and entries that agree on scope and direction are collapsed
52
+ * to one: two spellings of one configuration reach the same key, while two configurations that
53
+ * differ in any member cannot. The collapse reads only what the key reads — two entries alike in
54
+ * scope and direction but differing in a key the form does not see (`density`, `unique`) collapse
55
+ * too, and `source.indexes` keeps both for a consumer that refuses what the key cannot express.
56
+ */
57
+ export declare function canonicalSingleFieldIndexes(indexes: readonly SingleFieldIndex[]): CanonicalSingleFieldIndex[];
58
+ /**
59
+ * The counterpart of `analyse` for the document's other half. An absent `fieldOverrides` is an
60
+ * empty set, which is what a hand-written file without the key means.
61
+ */
62
+ export declare function analyseOverrides(document: IndexDocument): AnalysedOverride[];
35
63
  //# sourceMappingURL=key.d.ts.map
package/dist/key.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"key.d.ts","sourceRoot":"","sources":["../src/key.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,cAAc,EAAkB,aAAa,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAE3G,eAAO,MAAM,UAAU,aAAa,CAAC;AAErC;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,UAAU,GAAG,MAAM,CAQxD;AAED;;;;;GAKG;AACH,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,SAAS,UAAU,EAAE,GAAG,MAAM,CAM3E;AAED,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,cAAc,EAAE,CAAC;IACzB,+FAA+F;IAC/F,sBAAsB,EAAE,MAAM,GAAG,IAAI,CAAC;CACvC;AAED;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,SAAS,UAAU,EAAE,GAAG,eAAe,CAS9E;AAMD,wBAAgB,WAAW,CAAC,KAAK,EAAE,cAAc,GAAG,MAAM,CAEzD;AAED,wBAAgB,QAAQ,CACtB,eAAe,EAAE,MAAM,EACvB,UAAU,EAAE,MAAM,EAClB,MAAM,EAAE,SAAS,cAAc,EAAE,GAChC,MAAM,CAER;AAED,kGAAkG;AAClG,wBAAgB,OAAO,CAAC,QAAQ,EAAE,aAAa,GAAG,aAAa,EAAE,CAEhE"}
1
+ {"version":3,"file":"key.d.ts","sourceRoot":"","sources":["../src/key.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,aAAa,EACb,gBAAgB,EAChB,cAAc,EACd,yBAAyB,EAGzB,aAAa,EACb,UAAU,EACV,gBAAgB,EACjB,MAAM,YAAY,CAAC;AAGpB,eAAO,MAAM,UAAU,aAAa,CAAC;AAErC,2GAA2G;AAC3G,MAAM,MAAM,WAAW,GAAG,IAAI,CAAC,UAAU,EAAE,OAAO,GAAG,aAAa,GAAG,cAAc,CAAC,CAAC;AAErF;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,WAAW,GAAG,MAAM,CAQzD;AAED;;;;;GAKG;AACH,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,SAAS,UAAU,EAAE,GAAG,MAAM,CAM3E;AAED,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,cAAc,EAAE,CAAC;IACzB,+FAA+F;IAC/F,sBAAsB,EAAE,MAAM,GAAG,IAAI,CAAC;CACvC;AAED;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,SAAS,UAAU,EAAE,GAAG,eAAe,CAS9E;AAMD,wBAAgB,WAAW,CAAC,KAAK,EAAE,cAAc,GAAG,MAAM,CAEzD;AAED,wBAAgB,QAAQ,CACtB,eAAe,EAAE,MAAM,EACvB,UAAU,EAAE,MAAM,EAClB,MAAM,EAAE,SAAS,cAAc,EAAE,GAChC,MAAM,CAER;AAED,kGAAkG;AAClG,wBAAgB,OAAO,CAAC,QAAQ,EAAE,aAAa,GAAG,aAAa,EAAE,CAEhE;AAED,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,yBAAyB,GAAG,MAAM,CAE/E;AAED;;;;;;GAMG;AACH,wBAAgB,WAAW,CACzB,eAAe,EAAE,MAAM,EACvB,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,SAAS,yBAAyB,EAAE,GAC5C,MAAM,CAER;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,2BAA2B,CACzC,OAAO,EAAE,SAAS,gBAAgB,EAAE,GACnC,yBAAyB,EAAE,CAe7B;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,aAAa,GAAG,gBAAgB,EAAE,CAI5E"}
package/dist/key.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { compareStrings } from './collections.js';
1
2
  export const NAME_FIELD = '__name__';
2
3
  /**
3
4
  * The direction a field contributes to the canonical key.
@@ -61,6 +62,63 @@ export function indexKey(collectionGroup, queryScope, fields) {
61
62
  export function analyse(document) {
62
63
  return document.indexes.map((source, position) => analyseIndex(source, position));
63
64
  }
65
+ export function formatSingleFieldIndex(index) {
66
+ return `${index.queryScope}:${index.direction}`;
67
+ }
68
+ /**
69
+ * The canonical key of a field override (SPEC §5, *Canonical override key*).
70
+ *
71
+ * The same three-part shape as `indexKey`, with the field path where the query scope sits and the
72
+ * scope inside each entry — a single-field index names one field, so what varies per entry is the
73
+ * scope. `indexes` must already be in canonical order; the key is a rendering, not a sort.
74
+ */
75
+ export function overrideKey(collectionGroup, fieldPath, indexes) {
76
+ return `${collectionGroup}::${fieldPath}::${indexes.map(formatSingleFieldIndex).join('|')}`;
77
+ }
78
+ /**
79
+ * Reduce an override's declared set to canonical form.
80
+ *
81
+ * A composite index's fields are a sequence and their order is part of the key; an override's
82
+ * indexes are a set, and Firestore holds at most one single-field index per (scope, direction) of
83
+ * a field. So the entries are sorted, and entries that agree on scope and direction are collapsed
84
+ * to one: two spellings of one configuration reach the same key, while two configurations that
85
+ * differ in any member cannot. The collapse reads only what the key reads — two entries alike in
86
+ * scope and direction but differing in a key the form does not see (`density`, `unique`) collapse
87
+ * too, and `source.indexes` keeps both for a consumer that refuses what the key cannot express.
88
+ */
89
+ export function canonicalSingleFieldIndexes(indexes) {
90
+ const seen = new Set();
91
+ const canonical = [];
92
+ for (const index of indexes) {
93
+ const entry = { queryScope: index.queryScope, direction: fieldDirection(index) };
94
+ // Not the rendered `scope:direction`, which is not injective — nothing forbids `:` in either
95
+ // part. `JSON.stringify` over the pair is, without an assumption about the vocabulary.
96
+ const identity = JSON.stringify([entry.queryScope, entry.direction]);
97
+ if (seen.has(identity))
98
+ continue;
99
+ seen.add(identity);
100
+ canonical.push(entry);
101
+ }
102
+ return canonical.sort((a, b) => compareStrings(a.queryScope, b.queryScope) || compareStrings(a.direction, b.direction));
103
+ }
104
+ /**
105
+ * The counterpart of `analyse` for the document's other half. An absent `fieldOverrides` is an
106
+ * empty set, which is what a hand-written file without the key means.
107
+ */
108
+ export function analyseOverrides(document) {
109
+ return (document.fieldOverrides ?? []).map((source, position) => analyseOverride(source, position));
110
+ }
111
+ function analyseOverride(source, position) {
112
+ const indexes = canonicalSingleFieldIndexes(source.indexes);
113
+ return {
114
+ source,
115
+ position,
116
+ collectionGroup: source.collectionGroup,
117
+ fieldPath: source.fieldPath,
118
+ indexes,
119
+ key: overrideKey(source.collectionGroup, source.fieldPath, indexes),
120
+ };
121
+ }
64
122
  function analyseIndex(source, position) {
65
123
  const { fields, redundantNameDirection } = canonicalFields(source.fields);
66
124
  return {
package/dist/key.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"key.js","sourceRoot":"","sources":["../src/key.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"key.js","sourceRoot":"","sources":["../src/key.ts"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"parse.d.ts","sourceRoot":"","sources":["../src/parse.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAkB,aAAa,EAAc,MAAM,YAAY,CAAC;AAE5E;;;GAGG;AACH,qBAAa,mBAAoB,SAAQ,KAAK;IAC5C,SAAkB,IAAI,yBAAyB;CAChD;AAED,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,aAAa,CAUzD;AAED,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,OAAO,GAAG,aAAa,CAa5D"}
1
+ {"version":3,"file":"parse.d.ts","sourceRoot":"","sources":["../src/parse.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAGV,aAAa,EAGd,MAAM,YAAY,CAAC;AAEpB;;;GAGG;AACH,qBAAa,mBAAoB,SAAQ,KAAK;IAC5C,SAAkB,IAAI,yBAAyB;CAChD;AAED,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,aAAa,CAUzD;AAED,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,OAAO,GAAG,aAAa,CA4B5D"}
package/dist/parse.js CHANGED
@@ -29,7 +29,19 @@ export function validateDocument(raw) {
29
29
  throw new MalformedInputError('"indexes" must be an array');
30
30
  }
31
31
  const validated = indexes.map((entry, i) => validateIndex(entry, `indexes[${i}]`));
32
- return { ...raw, indexes: validated };
32
+ // Optional, because a hand-written file usually has none and the Firebase CLI omits the key when
33
+ // an export has none. Present, it is validated to the same depth as `indexes`: an override this
34
+ // tool cannot read is one it would otherwise pass through *as* read, and the consumer that
35
+ // reconciles it against a live listing would then vouch for a field it never examined (#53).
36
+ const overrides = raw['fieldOverrides'];
37
+ if (overrides === undefined) {
38
+ return { ...raw, indexes: validated };
39
+ }
40
+ if (!Array.isArray(overrides)) {
41
+ throw new MalformedInputError('"fieldOverrides" must be an array');
42
+ }
43
+ const validatedOverrides = overrides.map((entry, i) => validateOverride(entry, `fieldOverrides[${i}]`));
44
+ return { ...raw, indexes: validated, fieldOverrides: validatedOverrides };
33
45
  }
34
46
  function validateIndex(raw, path) {
35
47
  if (!isObject(raw)) {
@@ -55,6 +67,48 @@ function validateField(raw, path) {
55
67
  throw new MalformedInputError(`${path}: must be an object`);
56
68
  }
57
69
  const fieldPath = requireString(raw['fieldPath'], `${path}: "fieldPath"`);
70
+ validateConfig(raw, path);
71
+ return { ...raw, fieldPath };
72
+ }
73
+ function validateOverride(raw, path) {
74
+ if (!isObject(raw)) {
75
+ throw new MalformedInputError(`${path}: must be an object`);
76
+ }
77
+ const collectionGroup = requireString(raw['collectionGroup'], `${path}: "collectionGroup"`);
78
+ const fieldPath = requireString(raw['fieldPath'], `${path}: "fieldPath"`);
79
+ // Empty is not the error it is for a composite index: an override declaring no indexes is an
80
+ // exemption, which is a configuration Firestore holds and an export writes out.
81
+ const indexes = raw['indexes'];
82
+ if (!Array.isArray(indexes)) {
83
+ throw new MalformedInputError(`${path}: "indexes" must be an array`);
84
+ }
85
+ const validatedIndexes = indexes.map((entry, i) => validateSingleFieldIndex(entry, `${path}.indexes[${i}]`));
86
+ // Checked for type only. Whether the field has a TTL policy is not something a canonical form
87
+ // reads, but a value that is not a boolean is not a TTL declaration the Firebase CLI would write.
88
+ if (raw['ttl'] !== undefined && typeof raw['ttl'] !== 'boolean') {
89
+ throw new MalformedInputError(`${path}: "ttl" must be a boolean`);
90
+ }
91
+ return { ...raw, collectionGroup, fieldPath, indexes: validatedIndexes };
92
+ }
93
+ function validateSingleFieldIndex(raw, path) {
94
+ if (!isObject(raw)) {
95
+ throw new MalformedInputError(`${path}: must be an object`);
96
+ }
97
+ // Absent, the scope is `COLLECTION`: the Firebase CLI's validator checks `queryScope` only when
98
+ // it is present and its own exports always write it, so an omission is a hand-written file, and
99
+ // `COLLECTION` is the default the CLI itself supplies when it upgrades an old-format composite
100
+ // index. A present value is still held to a string.
101
+ const queryScope = raw['queryScope'] === undefined
102
+ ? 'COLLECTION'
103
+ : requireString(raw['queryScope'], `${path}: "queryScope"`);
104
+ validateConfig(raw, path);
105
+ return { ...raw, queryScope };
106
+ }
107
+ /**
108
+ * The one-of check a composite index's field and a single-field index share: exactly one of
109
+ * `order`, `arrayConfig`, and `vectorConfig`, each of the right type.
110
+ */
111
+ function validateConfig(raw, path) {
58
112
  const configured = ['order', 'arrayConfig', 'vectorConfig'].filter((name) => raw[name] !== undefined);
59
113
  if (configured.length === 0) {
60
114
  throw new MalformedInputError(`${path}: needs one of "order", "arrayConfig", or "vectorConfig"`);
@@ -71,7 +125,6 @@ function validateField(raw, path) {
71
125
  if (raw['vectorConfig'] !== undefined && !isObject(raw['vectorConfig'])) {
72
126
  throw new MalformedInputError(`${path}: "vectorConfig" must be an object`);
73
127
  }
74
- return { ...raw, fieldPath };
75
128
  }
76
129
  function requireString(value, label) {
77
130
  if (typeof value !== 'string') {
package/dist/parse.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"parse.js","sourceRoot":"","sources":["../src/parse.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"parse.js","sourceRoot":"","sources":["../src/parse.ts"],"names":[],"mappings":"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"}
package/dist/types.d.ts CHANGED
@@ -23,10 +23,46 @@ export interface CompositeIndex {
23
23
  fields: IndexField[];
24
24
  [key: string]: unknown;
25
25
  }
26
+ /**
27
+ * One entry of a field override's `indexes` array: a single-field index.
28
+ *
29
+ * The same three configs as `IndexField`, minus `fieldPath` — the override names the field once,
30
+ * above — plus the `queryScope` a composite index carries at the top level. A single-field index is
31
+ * one field, so the scope is the only thing that varies from entry to entry besides the direction.
32
+ */
33
+ export interface SingleFieldIndex {
34
+ /**
35
+ * `COLLECTION` or `COLLECTION_GROUP` in practice; not validated against an enumeration. `parse.ts`
36
+ * fills in `COLLECTION` when the declaration omits it, as the Firebase CLI does.
37
+ */
38
+ queryScope: string;
39
+ order?: string;
40
+ arrayConfig?: string;
41
+ vectorConfig?: Record<string, unknown>;
42
+ [key: string]: unknown;
43
+ }
44
+ /**
45
+ * One entry of the document's `fieldOverrides` array.
46
+ *
47
+ * Firestore indexes every field of every document by default; an override replaces that default
48
+ * for one field of one collection group with the set declared in `indexes`. The set is the *whole*
49
+ * configuration the field ends up with, not a delta — an export materialises the defaults an
50
+ * override keeps — and an empty set is an exemption: the field is not indexed at all.
51
+ *
52
+ * `ttl` is carried and not analysed. It decides when a document is deleted, not which queries are
53
+ * served, so nothing that reads a canonical form has a use for it.
54
+ */
55
+ export interface FieldOverride {
56
+ collectionGroup: string;
57
+ fieldPath: string;
58
+ indexes: SingleFieldIndex[];
59
+ ttl?: boolean;
60
+ [key: string]: unknown;
61
+ }
26
62
  /** A parsed and validated `firestore.indexes.json`. */
27
63
  export interface IndexDocument {
28
64
  indexes: CompositeIndex[];
29
- fieldOverrides?: unknown[];
65
+ fieldOverrides?: FieldOverride[];
30
66
  [key: string]: unknown;
31
67
  }
32
68
  /** A field reduced to the two things the canonical key is built from. */
@@ -52,6 +88,33 @@ export interface AnalysedIndex {
52
88
  */
53
89
  readonly redundantNameDirection: string | null;
54
90
  }
91
+ /** A single-field index reduced to the two things its canonical form is built from. */
92
+ export interface CanonicalSingleFieldIndex {
93
+ queryScope: string;
94
+ /** `ASCENDING`, `DESCENDING`, `CONTAINS`, or `VECTOR(<dimension>)`. */
95
+ direction: string;
96
+ }
97
+ /**
98
+ * A field override with its canonical form precomputed, the counterpart of `AnalysedIndex`.
99
+ *
100
+ * Built by `analyseOverrides` and shared with `@indexwright/record`, which reconciles it against a
101
+ * live field listing; a rule reading `fieldOverrides` reads this rather than the source (issues #53
102
+ * and #54 ask for one model, not two).
103
+ */
104
+ export interface AnalysedOverride {
105
+ /** The declaration as written, including any keys indexwright does not understand. */
106
+ readonly source: FieldOverride;
107
+ /** Position within the document's `fieldOverrides` array, for messages that need to point at one. */
108
+ readonly position: number;
109
+ readonly collectionGroup: string;
110
+ readonly fieldPath: string;
111
+ /**
112
+ * The declared set, in canonical order: sorted by query scope, then direction, with entries alike
113
+ * in both collapsed to one (`source.indexes` keeps them all). Empty for an exemption.
114
+ */
115
+ readonly indexes: readonly CanonicalSingleFieldIndex[];
116
+ readonly key: string;
117
+ }
55
118
  export declare const RULE_IDS: readonly ["scope-mismatch", "field-order-variant", "explicit-name-field", "quota-headroom"];
56
119
  export type RuleId = (typeof RULE_IDS)[number];
57
120
  /** A single warning. Never an error: see SPEC §8. */
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,8CAA8C;AAC9C,MAAM,WAAW,UAAU;IACzB,SAAS,EAAE,MAAM,CAAC;IAClB,qFAAqF;IACrF,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,oEAAoE;IACpE,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACvC,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED,mDAAmD;AACnD,MAAM,WAAW,cAAc;IAC7B,eAAe,EAAE,MAAM,CAAC;IACxB,4FAA4F;IAC5F,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,UAAU,EAAE,CAAC;IACrB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED;;;;;;GAMG;AACH,MAAM,WAAW,gBAAgB;IAC/B;;;OAGG;IACH,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACvC,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED;;;;;;;;;;GAUG;AACH,MAAM,WAAW,aAAa;IAC5B,eAAe,EAAE,MAAM,CAAC;IACxB,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,gBAAgB,EAAE,CAAC;IAC5B,GAAG,CAAC,EAAE,OAAO,CAAC;IACd,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED,uDAAuD;AACvD,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,cAAc,EAAE,CAAC;IAC1B,cAAc,CAAC,EAAE,aAAa,EAAE,CAAC;IACjC,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED,yEAAyE;AACzE,MAAM,WAAW,cAAc;IAC7B,SAAS,EAAE,MAAM,CAAC;IAClB,uEAAuE;IACvE,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,6FAA6F;AAC7F,MAAM,WAAW,aAAa;IAC5B,sFAAsF;IACtF,QAAQ,CAAC,MAAM,EAAE,cAAc,CAAC;IAChC,8FAA8F;IAC9F,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IACjC,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,6EAA6E;IAC7E,QAAQ,CAAC,MAAM,EAAE,SAAS,cAAc,EAAE,CAAC;IAC3C,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB;;;OAGG;IACH,QAAQ,CAAC,sBAAsB,EAAE,MAAM,GAAG,IAAI,CAAC;CAChD;AAED,uFAAuF;AACvF,MAAM,WAAW,yBAAyB;IACxC,UAAU,EAAE,MAAM,CAAC;IACnB,uEAAuE;IACvE,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;;;;;GAMG;AACH,MAAM,WAAW,gBAAgB;IAC/B,sFAAsF;IACtF,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAC;IAC/B,qGAAqG;IACrG,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IACjC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B;;;OAGG;IACH,QAAQ,CAAC,OAAO,EAAE,SAAS,yBAAyB,EAAE,CAAC;IACvD,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;CACtB;AAED,eAAO,MAAM,QAAQ,6FAKX,CAAC;AAEX,MAAM,MAAM,MAAM,GAAG,CAAC,OAAO,QAAQ,CAAC,CAAC,MAAM,CAAC,CAAC;AAE/C,qDAAqD;AACrD,MAAM,WAAW,OAAO;IACtB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,6EAA6E;IAC7E,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC;IAChB,wFAAwF;IACxF,OAAO,EAAE,MAAM,EAAE,CAAC;CACnB;AAED,wEAAwE;AACxE,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,WAAW;IAC1B,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,uEAAuE;IACvE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAChC;AAED,MAAM,WAAW,UAAU;IACzB,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,OAAO,EAAE,WAAW,CAAC;IACrB,QAAQ,EAAE,OAAO,EAAE,CAAC;IACpB,MAAM,EAAE,SAAS,EAAE,CAAC;CACrB;AAED,MAAM,WAAW,WAAW;IAC1B,mEAAmE;IACnE,KAAK,EAAE,MAAM,CAAC;IACd,8DAA8D;IAC9D,cAAc,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,aAAa,CAAC;IACxB,OAAO,EAAE,SAAS,aAAa,EAAE,CAAC;IAClC,OAAO,EAAE,WAAW,CAAC;CACtB;AAED,MAAM,WAAW,IAAI;IACnB,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,kCAAkC;IAClC,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,KAAK,CAAC,OAAO,EAAE,WAAW,GAAG,OAAO,EAAE,CAAC;CACxC;AAED,MAAM,MAAM,YAAY,GAAG,MAAM,GAAG,MAAM,GAAG,QAAQ,CAAC"}
package/dist/types.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAsDH,MAAM,CAAC,MAAM,QAAQ,GAAG;IACtB,gBAAgB;IAChB,qBAAqB;IACrB,qBAAqB;IACrB,gBAAgB;CACR,CAAC"}
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAyHH,MAAM,CAAC,MAAM,QAAQ,GAAG;IACtB,gBAAgB;IAChB,qBAAqB;IACrB,qBAAqB;IACrB,gBAAgB;CACR,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "indexwright",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Linter for Firestore composite index declarations (firestore.indexes.json)",
5
5
  "keywords": [
6
6
  "firestore",
@@ -10,18 +10,16 @@
10
10
  "linter",
11
11
  "static-analysis"
12
12
  ],
13
- "homepage": "https://github.com/uny/indexwright#readme",
13
+ "homepage": "https://github.com/uny/indexwright/tree/main/packages/indexwright#readme",
14
14
  "bugs": "https://github.com/uny/indexwright/issues",
15
15
  "repository": {
16
16
  "type": "git",
17
- "url": "git+https://github.com/uny/indexwright.git"
17
+ "url": "git+https://github.com/uny/indexwright.git",
18
+ "directory": "packages/indexwright"
18
19
  },
19
20
  "license": "Apache-2.0",
20
21
  "author": "Yuki Nagai",
21
22
  "type": "module",
22
- "workspaces": [
23
- "packages/*"
24
- ],
25
23
  "bin": {
26
24
  "indexwright": "dist/cli.js"
27
25
  },
@@ -36,22 +34,18 @@
36
34
  "files": [
37
35
  "dist",
38
36
  "README.md",
39
- "SPEC.md",
40
37
  "CHANGELOG.md",
41
38
  "LICENSE"
42
39
  ],
43
40
  "engines": {
44
41
  "node": ">=22"
45
42
  },
43
+ "publishConfig": {
44
+ "access": "public"
45
+ },
46
46
  "scripts": {
47
- "build": "tsc --build --force tsconfig.json packages/record/tsconfig.json",
48
- "typecheck": "tsc --noEmit && tsc --noEmit -p packages/record/tsconfig.json",
49
- "test": "npm run build && node --test \"test/*.test.js\" \"packages/*/test/*.test.js\"",
50
- "verify-package": "npm run build && node scripts/verify-package.mjs && node scripts/verify-record-package.mjs",
47
+ "build": "tsc --build --force",
48
+ "typecheck": "tsc --noEmit",
51
49
  "prepublishOnly": "npm run build"
52
- },
53
- "devDependencies": {
54
- "@types/node": "^22.10.2",
55
- "typescript": "^5.7.2"
56
50
  }
57
51
  }
package/SPEC.md DELETED
@@ -1,683 +0,0 @@
1
- # indexwright — Specification
2
-
3
- Linter and query-coverage checker for Firestore composite indexes.
4
-
5
- Status: **v0.2.0 (draft)**. Pre-1.0: rules and CLI surface may change between minor versions.
6
-
7
- ---
8
-
9
- ## 1. Why this exists
10
-
11
- Firestore composite indexes are declared as data (`firestore.indexes.json`) and applied as
12
- whole-state. Three properties of the platform make that declaration hard to get right, and none of
13
- them are addressed by existing tooling.
14
-
15
- **The emulator does not enforce composite indexes.** The Firestore emulator "does not track
16
- composite indexes and instead executes any valid query." A query that will fail in production with
17
- `FAILED_PRECONDITION` passes locally. Strict indexing in the emulator is not a planned feature.
18
-
19
- **Firestore exposes no per-index usage metrics.** Index entry reads are billed but do not appear in
20
- the usage dashboard, and there is no index-level metric. You cannot ask the platform which of your
21
- indexes are actually used, so you cannot safely identify unused ones.
22
-
23
- **The index-matching rule is undocumented.** Google's documentation states that field ordering must
24
- be specified, but does not define how field order maps to query shape — in particular whether the
25
- relative order of equality and `array-contains` fields before the range fields affects matching.
26
- This means index changes cannot be validated against documentation; only empirically.
27
-
28
- The asymmetry is stark: security rules have an official test harness
29
- (`@firebase/rules-unit-testing`) and can be verified in the emulator. **Composite indexes have no
30
- equivalent.** A survey of GitHub and npm found no linter for `firestore.indexes.json`; the only
31
- adjacent projects are three abandoned index *generators* (0–6 stars, last pushed 2021–2025).
32
-
33
- Consequently, index declarations drift into states that are detectable by inspection but that
34
- nothing inspects: inconsistent `queryScope` within a collection, near-duplicate indexes that differ
35
- only in field order, artifacts of round-tripping through live exports, and silent approach to the
36
- per-database index quota.
37
-
38
- ## 2. What this is not
39
-
40
- **indexwright cannot determine whether an index is needed.** That question requires the queries,
41
- which live in application code, not in the index declaration. No rule in this specification asserts
42
- that an index is unnecessary, and no output should be read as authorising a deletion.
43
-
44
- Deleting a composite index is not symmetric with adding one: an addition does not disturb existing
45
- queries while it backfills, but a deletion breaks its queries the moment it takes effect, and
46
- restoring it requires a fresh backfill. **A linter that only sees the declaration must never be the
47
- basis for a deletion.**
48
-
49
- What indexwright asserts is narrower and safer: *these declarations are structurally inconsistent
50
- with each other, and a human should look.*
51
-
52
- ## 3. Scope
53
-
54
- ### v0.1.0
55
-
56
- Static analysis of index declaration files. Four rules (§5). No network access, no credentials, no
57
- Firestore connection.
58
-
59
- ### Planned
60
-
61
- - **v0.2 — query capture.** The Firestore emulator speaks the public Firestore v1 gRPC API in
62
- plaintext on a local port. An intercepting proxy can decode `RunQuery` requests and record the
63
- observed `StructuredQuery` shapes, yielding a query corpus harvested from execution rather than
64
- hand-written. This is independent of language and framework, because it operates on the wire
65
- protocol rather than on source code — but not of transport: the emulator also serves the Firebase
66
- Web SDK over WebChannel rather than gRPC, and a suite driven from a browser does not pass through
67
- a gRPC proxy at all. v0.2 captures the gRPC transport, which is what server-side SDKs use. The
68
- corpus format is specified in §7.
69
- - **v0.3 — coverage check.** Replay a captured corpus against a throwaway Firestore database that
70
- has the candidate index set applied, and report queries that fail with `FAILED_PRECONDITION`.
71
- The oracle is Firestore itself; indexwright does not reimplement the undocumented matching rule.
72
-
73
- The v0.2/v0.3 split is deliberate: capture is cheap and offline, while the coverage decision is
74
- delegated to the platform. Reimplementing index matching would risk emitting false
75
- `FAILED_PRECONDITION` verdicts and blocking development on a rule that is not published.
76
-
77
- **Packaging of v0.2/v0.3.** `record` and `check` run inside an adopter's project, so whatever they
78
- depend on lands in an adopter's tree, and the build-time carve-out of §8 does not reach them. They
79
- ship as a separate package, `@indexwright/record`. **`indexwright` itself acquires no runtime
80
- dependency, in any version.**
81
-
82
- The cost being separated is `check`'s, not `record`'s. Replay executes captured shapes against a
83
- real database and reads the status Firestore answers with, which means a Firestore client — the
84
- thing §8 exists to keep out of a linter that runs on every push, in every CI job, in projects that
85
- may never touch Firestore from a server. `check` runs where a project is already talking to
86
- Firestore server-side and has already resolved that client transitively. The dependency is added
87
- where it is very likely already resolved, and is absent where it would be new.
88
-
89
- Capture turned out not to need one. An earlier draft of this section justified the split with a
90
- gRPC stack for `record` too — `@grpc/grpc-js` and protobuf definitions for the Firestore v1 API, on
91
- the grounds that hand-writing a decoder for a wire format owned by someone else is not a cost worth
92
- paying. Neither half held. `@grpc/grpc-js` implements clients and servers for services known at
93
- build time; a transparent proxy is `node:http2` and raw frames, and grpc-js has no part in it. And
94
- §7 fixes a closed operator vocabulary and requires an unrecognised enum value to be counted rather
95
- than named, so the enum table has to exist in-tree whichever library reads the bytes — what a
96
- protobuf runtime would add on top of it is varint and length-delimited parsing over field numbers
97
- that a released `.proto` cannot renumber. `@indexwright/record` v0.2 therefore declares no runtime
98
- dependency either, and takes one when `check` arrives.
99
-
100
- It does not depend on `indexwright` yet. Capture reads no index declarations, so the index model
101
- and the `json` contract are `check`'s needs rather than `record`'s. Declaring the dependency early
102
- would put a package in an adopter's tree that nothing imports.
103
-
104
- The cost is a second package to discover. `indexwright record` in an installation that has only the
105
- linter must say where the verb lives, not report an unknown command.
106
-
107
- **Known limit of v0.2/v0.3:** coverage is bounded by what actually exercises the proxy. A query that
108
- no test issues is not observed, and absence of observation is not evidence that an index is unused.
109
- That much is inherent. Two further gaps are not, and are implementation gaps v0.2 ships with: a
110
- suite driven through the Firebase Web SDK does not reach a gRPC proxy at all, and a query issued as
111
- a snapshot listener travels by `Listen` rather than `RunQuery` and is counted rather than recorded
112
- (§7). Both narrow what a corpus covers without narrowing what it appears to cover, which is why §7
113
- counts them out loud.
114
-
115
- ## 4. CLI
116
-
117
- ```
118
- indexwright lint <file...> [options]
119
-
120
- Options:
121
- --format <fmt> text (default) | json | github
122
- --max-warnings <n> exit 1 if warnings exceed n (default: unlimited → always exit 0)
123
- --rule <id> run only the given rule; repeatable
124
- --disable <id> skip the given rule; repeatable
125
- --quota <n> per-database composite index limit for R4 (default: 1000)
126
- --quota-threshold <p> warn above this fraction of the limit (default: 0.8)
127
- ```
128
-
129
- ### Exit codes
130
-
131
- | Code | Meaning |
132
- |-----:|:--------|
133
- | 0 | Completed. Warnings may have been emitted (default policy). |
134
- | 1 | Warning count exceeded `--max-warnings`. |
135
- | 2 | Usage error, unreadable file, or malformed input. |
136
-
137
- **The default is exit 0 even with findings.** A linter whose rules have unmeasured false-positive
138
- rates must not gate a pipeline by default. Adopters opt into enforcement with `--max-warnings`
139
- once they have measured their own noise level.
140
-
141
- ### Input
142
-
143
- Any file in the `firestore.indexes.json` shape:
144
-
145
- ```jsonc
146
- {
147
- "indexes": [
148
- {
149
- "collectionGroup": "…",
150
- "queryScope": "COLLECTION" | "COLLECTION_GROUP",
151
- "fields": [ { "fieldPath": "…", "order": "ASCENDING" | "DESCENDING" }
152
- | { "fieldPath": "…", "arrayConfig": "CONTAINS" }
153
- | { "fieldPath": "…", "vectorConfig": { … } } ],
154
- "density": "…" // optional, passed through
155
- }
156
- ],
157
- "fieldOverrides": [ … ] // parsed, not analysed in v0.1.0
158
- }
159
- ```
160
-
161
- Multiple files may be passed; each is analysed independently. Rules are not applied across files.
162
-
163
- ### Validation
164
-
165
- A file is **malformed** when it is not valid JSON, when the top level is not an object, when
166
- `indexes` is absent or is not an array, when an index lacks `collectionGroup`, `queryScope`, or a
167
- non-empty `fields`, or when a field lacks `fieldPath` or declares none — or more than one — of
168
- `order`, `arrayConfig`, and `vectorConfig`.
169
-
170
- Nothing beyond that is refused. The *values* of `queryScope` and `order` are not checked against an
171
- enumeration, unknown keys anywhere are ignored, and a declaration that repeats a `fieldPath` within
172
- one index — which does occur in live exports — is carried through rather than rejected. Refusing to
173
- analyse a file is the harshest outcome available to a tool that otherwise only warns, and it costs
174
- the reader every other index in the file, so it is reserved for input that cannot be read as an
175
- index declaration at all.
176
-
177
- Malformed input does not abort the run. Each file is read and analysed on its own: files that parse
178
- are linted and their findings reported, files that do not are reported as errors, and the process
179
- exits 2 once every file has been handled. Reporting only the first bad file would hide findings in
180
- the files that were fine.
181
-
182
- ## 5. Rules
183
-
184
- Every rule emits **warnings**, never errors. Each finding carries: rule id, file, a canonical index
185
- key, and a one-line reason. A finding that concerns the file as a whole rather than any particular
186
- index carries a **null** key.
187
-
188
- A rule whose subject is a *set* of indexes emits one grouped finding for that set, not one finding
189
- per member. The finding's `key` is the lexicographically smallest member key and `related` holds the
190
- remaining member keys, sorted ascending and de-duplicated. This keeps the warning count proportional
191
- to the number of problems rather than to the number of indexes, which matters because
192
- `--max-warnings` is counted in findings.
193
-
194
- ### Canonical index key
195
-
196
- ```
197
- <collectionGroup>::<queryScope>::<fieldPath>:<direction>|<fieldPath>:<direction>|…
198
- ```
199
-
200
- where `direction` is `ASCENDING`, `DESCENDING`, `CONTAINS`, or — for a `vectorConfig` field —
201
- `VECTOR(<dimension>)`, written `VECTOR(?)` when no dimension is declared. A trailing implicit
202
- `__name__` entry is stripped before forming the key, so that declarations that differ only in
203
- whether the document key is written explicitly resolve to the same resource.
204
-
205
- ### The implicit `__name__` direction
206
-
207
- Firestore appends the document key to every composite index, and its direction is not declared, so
208
- stripping it requires knowing which direction it would have had. indexwright defines that direction
209
- as **the `order` of the last preceding field that carries an `order`, or `ASCENDING` when no
210
- preceding field carries one** — the latter arising when the index ends with an `arrayConfig` field.
211
-
212
- This is not published behaviour. It is read off live exports, which render the key explicitly:
213
- `[type ASC, createdAt DESC, __name__ DESC]`, `[public ASC, startAt ASC, __name__ ASC]`, and
214
- `[isRecommended ASC, tags CONTAINS, __name__ ASC]` are all shapes an export produces.
215
-
216
- The definition is used only to decide whether a written `__name__` is redundant, and the decision is
217
- deliberately one-sided: a trailing `__name__` is stripped **only** when its direction equals the
218
- value above, and one that differs — `[totalNbUses DESC, __name__ ASC]` occurs in real exports — is
219
- treated as meaningful and kept. A wrong definition can therefore fail to merge two spellings of one
220
- index, but can never merge two indexes that are actually distinct.
221
-
222
- ---
223
-
224
- ### R1 · `scope-mismatch`
225
-
226
- **Detects.** Within one `collectionGroup`, more than one distinct `queryScope` is declared. One
227
- finding is emitted per affected `collectionGroup`, reporting how many indexes each scope holds.
228
-
229
- The scope with the fewest indexes is named as the minority, and the finding's keys are that scope's
230
- indexes. When no scope holds a strict minority — an even split — the finding says so instead of
231
- naming one, and reports the keys of the lexicographically first scope. It still fires: the detection
232
- condition is that the scopes disagree, and an even split is a disagreement. Suppressing it would
233
- narrow the rule to a shape the rule does not claim.
234
-
235
- **Rationale.** A `COLLECTION`-scoped index does not serve a collection-group query, and vice versa.
236
- When every other index on a collection uses one scope and a newly added one uses the other, the new
237
- declaration is frequently a mistake that will surface only as a production `FAILED_PRECONDITION`.
238
-
239
- **False positives.** Legitimate when an application queries the same collection both as a single
240
- collection and as a collection group. This is why the rule warns rather than fails.
241
-
242
- ---
243
-
244
- ### R2 · `field-order-variant`
245
-
246
- **Detects.** Two or more indexes that share `collectionGroup`, `queryScope`, and the same *set* of
247
- `fieldPath:direction` pairs, but declare them in different orders.
248
-
249
- "Same set" means the same *multiset*: a `fieldPath` that repeats within one index is unusual but not
250
- rejected (§4), and comparing multisets keeps the grouping well defined when it does. The comparison
251
- runs on the canonicalised field list, so an index that writes `__name__` explicitly groups with one
252
- that does not. Two indexes that are byte-identical do not constitute different orders and do not
253
- fire this rule.
254
-
255
- **Rationale.** Firestore treats a different field order as a different index. Each variant consumes
256
- write amplification, storage, and quota independently. Variants proliferate easily: a query change
257
- that reorders fields adds a new index without removing the old one, and round-tripping through a
258
- live export can reintroduce an ordering that was intended to be replaced.
259
-
260
- **False positives.** Legitimate when distinct queries genuinely require distinct orderings — for
261
- example, two queries that order by the same two fields in opposite directions. The finding asks for
262
- that justification to be recorded, not for one variant to be removed.
263
-
264
- **Output.** All members of the variant group are listed together, so the reader can judge the set
265
- rather than one member at a time.
266
-
267
- ---
268
-
269
- ### R3 · `explicit-name-field`
270
-
271
- **Detects.** An index whose **last** `fields` entry is an explicit `__name__` carrying the implicit
272
- default direction (§5, *The implicit `__name__` direction*). A `__name__` written anywhere other
273
- than last, or written last with a direction other than the default, is not flagged.
274
-
275
- **Rationale.** Firestore appends the document key implicitly; live exports render it explicitly.
276
- An explicit `__name__` in a hand-maintained declaration is therefore a signature of a value that
277
- round-tripped through an export rather than being authored directly. It does not change the
278
- resource identity, but it makes the file inconsistent and can mask genuine duplicates from
279
- naive text comparison.
280
-
281
- **False positives.** Explicit `__name__` with a direction that differs from the implicit default is
282
- meaningful and should not be flagged. The rule only fires on a trailing `__name__` matching the
283
- implicit default.
284
-
285
- ---
286
-
287
- ### R4 · `quota-headroom`
288
-
289
- **Detects.** The number of composite indexes in the file exceeds `--quota-threshold` of `--quota`.
290
-
291
- **Rationale.** Composite indexes are capped per database. The limit is reached gradually and
292
- silently; the first symptom is a failed index creation at deploy time, which is a poor moment to
293
- discover it. Reporting headroom continuously makes the trend visible.
294
-
295
- The comparison is a strict `count > quota × threshold`, so the defaults (1000, 0.8) fire at 801.
296
-
297
- **False positives.** None in principle; the threshold is configurable because appropriate headroom
298
- depends on the rate of index growth.
299
-
300
- **Output.** The finding is about the file, not about any one index, so its `key` is `null` and its
301
- `related` is empty. One finding per file at most.
302
-
303
- ## 6. Output formats
304
-
305
- **`text`** — human-readable, grouped by rule, intended for a terminal.
306
-
307
- **`json`** — a stable machine-readable shape:
308
-
309
- ```jsonc
310
- {
311
- "version": "0.1.0",
312
- "files": ["…"],
313
- "summary": { "warnings": 0, "errors": 0, "byRule": { "scope-mismatch": 0, … } },
314
- "findings": [
315
- {
316
- "rule": "scope-mismatch",
317
- "file": "…",
318
- "key": "…", // null for a finding about the file as a whole
319
- "message": "…",
320
- "related": ["…"] // other keys in the same finding group; [] when there are none
321
- }
322
- ],
323
- "errors": [
324
- { "file": "…", "message": "…" } // files that could not be read or parsed
325
- ]
326
- }
327
- ```
328
-
329
- `files` lists every file the run was given, whether or not it parsed, sorted by path. `byRule` holds
330
- one entry for every rule that ran after `--rule` and `--disable` were applied, including rules that
331
- found nothing; a rule that did not run is absent rather than zero. `findings` is sorted by file,
332
- then by rule in the order of §5, then by key with a null key sorting first. `related` is sorted
333
- ascending. `errors` is sorted by file. Every field is always present: `related` is `[]` rather than
334
- omitted, and `errors` is `[]` on a clean run.
335
-
336
- **`github`** — GitHub Actions workflow commands (`::warning file=…::`) plus a Markdown summary
337
- suitable for `$GITHUB_STEP_SUMMARY`.
338
-
339
- ## 7. Query corpus (v0.2)
340
-
341
- `record` writes a **query corpus**: the set of distinct query shapes observed on the emulator's wire
342
- during a run, conventionally `firestore.queries.json`. The corpus is the contract between capture
343
- (v0.2) and the coverage check (v0.3), and it is meant to be committed alongside
344
- `firestore.indexes.json` and reviewed in a diff like any other declaration.
345
-
346
- A query enters the corpus when its request is observed, whatever the server answers next. One that
347
- failed still describes something the application issues — and a query that failed *because an index
348
- was missing* is precisely the case v0.3 exists to find, so waiting for a successful status would
349
- drop the most interesting entries in the file.
350
-
351
- `record` writes the corpus whole, replacing whatever was there, and writes it atomically so that an
352
- interrupted run leaves the previous file intact rather than a truncated one. Replacing rather than
353
- merging is the honest reading: a corpus is evidence of what one run exercised, and a run that
354
- executed three tests did not exercise the other two hundred. The cost is that a filtered or aborted
355
- run produces a diff that deletes query shapes which still exist — which is a visible deletion a
356
- reviewer can question, where a merge would have produced a file that silently outlives the tests
357
- that justified it.
358
-
359
- ### What a shape is
360
-
361
- A shape is the part of a `StructuredQuery` that determines which index can serve it: the collection,
362
- the query scope, the filter tree, and the sort order. Everything else is discarded.
363
-
364
- **Values are not recorded.** A query's values are customer data, and the corpus is a file that
365
- persists in a repository. They are also unnecessary: index selection is a function of field paths,
366
- operators, and directions, not of what is compared against. Discarding them removes the part of a
367
- captured query most likely to carry a secret, and the only part that changes between runs of the
368
- same test.
369
-
370
- It does not make the corpus non-sensitive, and nothing here should be read as saying so. Field paths
371
- are recorded verbatim, and a field path is not always static: `members.alice@example.com` is an
372
- ordinary way to query a map, and it is at once a real identifier and a corpus entry that will never
373
- recur. Collection ids carry schema and sometimes tenancy. A corpus describes an application's data
374
- model, and it earns the access controls of the repository it is committed to — it does not become
375
- publishable by having had its values stripped.
376
-
377
- **Project and database are not recorded.** They are properties of the environment the capture ran
378
- in, not of the query, and recording them would make a corpus captured against one emulator instance
379
- look different from the same corpus captured against another.
380
-
381
- **`limit`, `offset`, cursors, and `select` are not recorded.** None of them changes which index
382
- serves the query; a projection is served by the index the underlying query already needs.
383
-
384
- **Occurrence counts are not recorded.** A corpus is a set, not a histogram. A count changes on every
385
- run without changing anything about what must be indexed, which would make the file churn in every
386
- diff and train reviewers to skim it. `record` reports counts on stderr, where they are useful for
387
- triage and where they do not have to be committed.
388
-
389
- ### Vocabulary
390
-
391
- The corpus reuses the declaration vocabulary of §5, so that a corpus entry and an index key can be
392
- read against each other with one translation, which the table below states in full: an
393
- `ARRAY_CONTAINS` filter is served by a field declared `arrayConfig: CONTAINS`.
394
-
395
- | Corpus | Source on the wire | Aligns with |
396
- |:--|:--|:--|
397
- | `collectionGroup` | `CollectionSelector.collection_id` | index `collectionGroup` |
398
- | `queryScope` — `COLLECTION` \| `COLLECTION_GROUP` | `all_descendants` | index `queryScope` |
399
- | `direction` — `ASCENDING` \| `DESCENDING` | `Order.direction` | field `order` |
400
- | `op` — `ARRAY_CONTAINS` | `FieldFilter.Operator` | field `arrayConfig: CONTAINS` |
401
-
402
- Operators are written with their protobuf enum names: `LESS_THAN`, `LESS_THAN_OR_EQUAL`,
403
- `GREATER_THAN`, `GREATER_THAN_OR_EQUAL`, `EQUAL`, `NOT_EQUAL`, `ARRAY_CONTAINS`, `IN`,
404
- `ARRAY_CONTAINS_ANY`, `NOT_IN` for a field filter; `IS_NAN`, `IS_NULL`, `IS_NOT_NAN`, `IS_NOT_NULL`
405
- for a unary filter; `AND` and `OR` for a composite. Enum names rather than SDK spellings, for the
406
- same reason §5 writes `ASCENDING` rather than `asc`: the file speaks Firestore's own vocabulary, so
407
- an entry can be read against a wire capture without a lookup table in between. The list is closed —
408
- an operator outside it is not written into the corpus under a name of indexwright's invention, and
409
- protobuf would hand it over as a bare number anyway. It is counted instead, as below.
410
-
411
- **A published default is applied; an unpublished one is not guessed at.** Every one of these enums
412
- has a zero value, and the two cases are not alike. `Order.direction` is documented to default to
413
- `ASCENDING`, so an `Order` that arrives as `DIRECTION_UNSPECIFIED` is recorded as `ASCENDING` —
414
- that is Firestore's own statement of what the value means, not indexwright's guess, and skipping the
415
- query instead would drop a real index requirement on the strength of a field the client simply left
416
- at its default. `OPERATOR_UNSPECIFIED`, an operator added after this specification was written, and
417
- any other value the vocabulary above cannot name have no such published meaning: the query is
418
- skipped and counted rather than guessed at. `StructuredQuery.from` is a repeated field, and the same
419
- line applies — an entry holds exactly one `collectionGroup`, so a query that does not carry exactly
420
- one selector with a non-empty `collection_id` is skipped, because the corpus has no way to say what
421
- it means rather than because its meaning is unknown.
422
-
423
- ### Canonical query key
424
-
425
- ```
426
- <collectionGroup>::<queryScope>::<where>::<orderBy>
427
- ```
428
-
429
- `<where>` serialises the filter tree: a field or unary filter as `<fieldPath>:<op>`, a composite as
430
- `<op>(<child>|<child>|…)`. The root is always a composite — `StructuredQuery.where` may be a bare
431
- field or unary filter, and is then wrapped in an `AND` — so a query with one filter serialises as
432
- `AND(status:EQUAL)` and a query with none as `AND()`. A root that is already a composite keeps its
433
- own operator: a top-level `OR` is `OR(…)`, never `AND(OR(…))`. `<orderBy>` is
434
- `<fieldPath>:<direction>` joined by `|`, and is empty when the query declares no sort order. A
435
- two-filter, two-sort query keys as:
436
-
437
- ```
438
- orders::COLLECTION::AND(amount:GREATER_THAN|status:EQUAL)::amount:DESCENDING|createdAt:ASCENDING
439
- ```
440
-
441
- `<collectionGroup>` and `<fieldPath>` are escaped before they are joined: a backslash becomes `\\`,
442
- each of `:`, `|`, `(`, `)` is prefixed with one, and every character below `U+0020` becomes `\uXXXX`.
443
- Enum names need no escaping but are subject to the same rule, so that decoding is uniform. The
444
- control-character clause is not about ambiguity but about §6's requirement that a value read off an
445
- untrusted input cannot forge a line of output: a key holding a raw newline would end the line it was
446
- printed on.
447
-
448
- Escaping is what makes the key injective, and the key is what entries are de-duplicated on. A field
449
- path may hold any of these characters — Firestore quotes a path in backticks precisely so that it
450
- can — and unescaped, one path spelled `` `a:EQUAL|b` `` serialises identically to two filters on `a`
451
- and `b`. Two distinct queries would then share an entry and only one of them would ever be replayed,
452
- which is the failure this section closes on: not a gap in coverage but a gap that looks like
453
- coverage.
454
-
455
- Both `AND` and `OR` are commutative, so a composite's children are sorted by their own serialised
456
- form, compared by Unicode code point, before joining. This makes the key independent of the order
457
- the filters were written in, which is what allows two spellings of one query to collapse into one
458
- corpus entry. A composite child carrying the same operator as its parent is flattened into it, since
459
- `AND(a|AND(b|c))` and `AND(a|b|c)` are one query; nesting under a *different* operator is meaningful
460
- and is kept. Sort order is *not* commutative and is preserved as sent.
461
-
462
- Repeated children are kept rather than de-duplicated, for the reason §5 compares multisets:
463
- `tier == "a" OR tier == "b"` is a two-disjunct query whose shape is `OR(tier:EQUAL|tier:EQUAL)`, and
464
- collapsing it to one disjunct would describe a query that was never issued.
465
-
466
- ### File shape
467
-
468
- ```jsonc
469
- {
470
- "corpusVersion": 1,
471
- "queries": [
472
- {
473
- "key": "orders::COLLECTION::AND(status:EQUAL)::createdAt:DESCENDING",
474
- "collectionGroup": "orders",
475
- "queryScope": "COLLECTION",
476
- "where": {
477
- "op": "AND",
478
- "filters": [
479
- { "fieldPath": "status", "op": "EQUAL" }
480
- ]
481
- },
482
- "orderBy": [
483
- { "fieldPath": "createdAt", "direction": "DESCENDING" }
484
- ]
485
- }
486
- ],
487
- "skipped": ["aggregation-query"]
488
- }
489
- ```
490
-
491
- A node carrying `filters` is a composite; a node carrying `fieldPath` is a leaf; no node carries
492
- both, and none carries neither. Every field is always present, as in §6: `where` is a composite even
493
- when the query filtered nothing, `orderBy` is `[]` rather than omitted, and `skipped` is `[]` on a
494
- run that discarded nothing. `queries` is sorted by `key` and `skipped` holds the distinct reasons
495
- observed, sorted ascending — a set, so that it is as diff-stable as the rest of the file. Both sorts
496
- compare by Unicode code point. Counts for each reason go to stderr.
497
-
498
- The stored `where` is the normalised tree the key was computed from, not the tree as it arrived:
499
- children sorted, same-operator composites flattened, exactly as *Canonical query key* describes. Two
500
- recorders that observe the same query in different spellings therefore write the same bytes, which
501
- they would not if the key were normalised and the tree left as sent — the entry that happened to
502
- win de-duplication would decide the file.
503
-
504
- `key` is derived from the four fields beneath it and carries nothing they do not: it is there so
505
- that entries can be sorted, de-duplicated, and cited in a review without re-serialising the tree. It
506
- is unique within `queries`. A reader handed an entry that breaks any of this — a `key` disagreeing
507
- with its own tree, a node that is both leaf and composite, a member the format does not define —
508
- refuses the file rather than repairing it, because the readings it would be choosing between are
509
- different queries and picking one is how a corpus comes to describe coverage it never had.
510
-
511
- `corpusVersion` is an integer that names the format, not the tool: it changes only when a corpus
512
- written by one version can no longer be read correctly by another, and it does not move when
513
- `@indexwright/record` is released. A reader handed a `corpusVersion` it does not know refuses the
514
- file and says so. It does not fall back to reading what it recognises, which would silently mis-read
515
- exactly the change the integer exists to announce.
516
-
517
- ### Implicit fields are not materialised
518
-
519
- Firestore appends the document key to every query's sort order, and promotes an inequality field
520
- into it. `record` neither adds those fields nor removes them: the corpus records the sort order as
521
- sent.
522
-
523
- What is sent is not the same for every client. The Node server SDK serialises the orders the
524
- application wrote and leaves the rest to the server; the Firebase Web SDK normalises first, so a
525
- `where('age', '>', 18)` reaches the wire already carrying `age ASCENDING, __name__ ASCENDING`. One
526
- application query therefore has two legitimate corpus shapes depending on which SDK issued it, and
527
- a corpus is comparable across runs of one project rather than across projects. Recording what was
528
- sent is what keeps that difference visible; normalising in either direction would hide it behind a
529
- rule this specification would have had to invent.
530
-
531
- This is deliberate. §5 has to define the implicit `__name__` direction because a linter comparing
532
- two declarations has no other way to tell whether they name the same resource. A corpus has no such
533
- need: the v0.3 oracle is Firestore itself, which applies the real rule. Materialising a guessed
534
- `__name__` into the corpus would put an unpublished behaviour into a durable file and make every
535
- entry wrong if the guess were wrong. When a human reads a corpus entry against an index key, §5's
536
- definition is the one that applies.
537
-
538
- ### Replay without values
539
-
540
- v0.3 must turn a corpus entry back into a query it can execute, and the entry has no values to put
541
- back. It synthesises them: a unary filter needs none, `IN`, `NOT_IN`, and `ARRAY_CONTAINS_ANY` need
542
- a one-element array, and everything else needs a single scalar. "Everything else" is a finite list
543
- rather than a default, because the vocabulary above enumerates the operators a corpus may hold and
544
- anything outside it was skipped at capture as `unsupported-shape`.
545
-
546
- Two shapes are not free-form and cannot be synthesised from the same rule. A `__name__` filter takes
547
- a document reference under the collection being queried, not a scalar, because Firestore validates
548
- the operand's type against the document key before it selects an index. And an entry whose root
549
- composite has no children replays with `where` omitted altogether, not as an empty `AND`: a wire
550
- `CompositeFilter` must carry at least one filter. Both would otherwise fail with `INVALID_ARGUMENT`,
551
- and an `INVALID_ARGUMENT` is not a `FAILED_PRECONDITION`. v0.3 reports the latter and never the
552
- former: a replay that comes back invalid is either a synthesis this section got wrong or a query
553
- that was already invalid when it was captured — the corpus admits those, since it records what was
554
- sent rather than what succeeded — and neither is a statement about the index set. Both are reported
555
- as un-replayable entries, which is a defect in the tooling or the test that issued them.
556
-
557
- This rests on the claim above — that index selection does not depend on the compared value or its
558
- type. The claim is consistent with how the field is indexed rather than the value, but it is not
559
- published, and it is the one assumption in v0.3 that a synthesised replay could get wrong. If it is
560
- false, replay reports `FAILED_PRECONDITION` where a real query would have succeeded, which is a
561
- false positive of exactly the kind §2 forbids acting on. v0.3 must test the claim before it reports.
562
-
563
- ### What is not captured
564
-
565
- `record` captures `RunQuery`. Everything else the proxy sees, it counts under one of the reasons
566
- below and records nothing:
567
-
568
- - **`Listen`** — a snapshot listener carries its query in `Target.QueryTarget.structured_query` and
569
- issues no `RunQuery` at all. Its index requirements are exactly those of the query it holds, so
570
- this is the one omission that costs coverage rather than preventing a misreport: a suite whose
571
- only exercise of a collection is `onSnapshot` yields a corpus with no entry for it. Counted as
572
- `listen-query`, and named in §3 as a gap rather than an inherent limit. Capturing it is the first
573
- extension worth making.
574
- - **`PartitionQuery`** — carries a `StructuredQuery` the same way, but as a bulk-read entry point
575
- rather than an application query. Counted as `partition-query`.
576
- - **`RunAggregationQuery`** — `count()`, `sum()`, and `average()` carry a `StructuredQuery` and have
577
- their own index requirements, which are not necessarily those of the underlying query. Recording
578
- the inner query would misreport them, so v0.2 counts them as `aggregation-query`. Capturing them
579
- properly is a v0.3-or-later extension.
580
- - **`find_nearest`** — vector search is counted as `vector-query` for the same reason: it is served
581
- by a `vectorConfig` index whose matching rule this specification does not yet model.
582
- - **A shape the vocabulary cannot express** — an unrecognised or unspecified enum value, or a `from`
583
- that does not name exactly one collection. Counted as `unsupported-shape`.
584
- - **A query-bearing RPC this specification does not model** — `ExecutePipeline` carries a
585
- `StructuredPipeline` rather than a `StructuredQuery`, and pipelines are outside v0.2. Counted as
586
- `unsupported-rpc`, so that `skipped: []` keeps meaning *nothing was declined* rather than *nothing
587
- the proxy happened to recognise was declined*. The same reason covers any other method on the
588
- Firestore service that is neither captured above nor on the list of methods known to carry no
589
- query at all: a method added after this was written is counted, not assumed harmless.
590
- - **A message compressed with an encoding `record` cannot undo** — gRPC marks compression per
591
- message and names the codec in `grpc-encoding`. `gzip` and `deflate` are undone and the message
592
- read; anything else is counted as `unsupported-encoding`. A client that negotiates a codec this
593
- package does not implement would otherwise have every query it sent vanish without trace.
594
- - **Bytes that do not parse** — a truncated frame, a body that ends mid-message, or a payload that
595
- is not the message the method declares. Counted as `undecodable-message`. This is the one reason
596
- that indicates a defect rather than a boundary: it should not occur against a conforming client,
597
- and a corpus carrying it is reporting that something read the wire wrongly.
598
-
599
- `skipped` draws from that closed vocabulary and nothing else. No text decoded from the wire is ever
600
- interpolated into a reason: the corpus is committed, and a free-text field fed by intercepted
601
- traffic is a second way for data to reach the repository after values have been stripped from the
602
- first.
603
-
604
- They are reported rather than dropped silently. §3's known limit — that coverage is bounded by what
605
- exercises the proxy — is about queries no test issues; a query that *was* issued and then discarded
606
- without trace would be a different and worse failure, because it would look like coverage.
607
-
608
- ## 8. Design principles
609
-
610
- **Warn, do not fail.** The rules encode heuristics whose false-positive rates are, at v0.1.0,
611
- unmeasured. Shipping them as blocking checks would teach users to suppress the tool. Enforcement is
612
- opt-in and per-adopter.
613
-
614
- **Never authorise a deletion.** See §2. No output phrasing may suggest that an index is unused or
615
- safe to remove.
616
-
617
- **No dependencies in `indexwright`.** A linter that pulls a dependency tree into a build pipeline
618
- undermines its own purpose. Argument parsing and formatting are implemented in-tree, and the
619
- published package declares no runtime dependencies. This is a property of the package rather than
620
- an aspiration of the project: it holds in every version, and a test asserts it.
621
-
622
- The principle is about what lands in an adopter's tree, so build- and test-time tooling that never
623
- ships is out of its scope. It is not a claim that no part of indexwright may depend on anything.
624
- Where a verb needs a library it cannot reasonably write — the Firestore client behind `check` (§3)
625
- — that verb ships as its own package instead of as a dependency of the linter. What the principle
626
- forbids is making every adopter of `lint` pay for it.
627
-
628
- `@indexwright/record` holds the line further than the principle requires: at v0.2 it declares no
629
- runtime dependency either, because capture turned out to need none (§3). That is not a promise it
630
- makes for every version, and `check` will end it.
631
-
632
- **No network, no credentials, in `lint`.** Static analysis must be runnable in any environment,
633
- including a sandboxed CI step with no cloud access. Network use is confined to the planned
634
- `record`/`check` verbs, which are separate commands in a separate package (§3).
635
-
636
- **Delegate undocumented semantics to the platform.** Where Firestore's behaviour is not published —
637
- principally index matching — indexwright measures rather than models. This bounds what the tool can
638
- claim, which is the point.
639
-
640
- **Deterministic output.** Findings are emitted in a stable sort order (file, rule, key), so that
641
- output can be diffed across runs.
642
-
643
- ## 9. Testing
644
-
645
- Rules are tested against small hand-written fixtures that isolate one condition each, with explicit
646
- positive and negative cases.
647
-
648
- **Assertions are never written against a real project's index file.** A test that asserts a finding
649
- count over live data fails whenever that data legitimately changes, which trains maintainers to
650
- edit the test rather than read it. Fixtures encode the invariant; real files are for manual
651
- exploration only.
652
-
653
- ## 10. Compatibility
654
-
655
- - Node.js ≥ 22, ESM.
656
- - Input schema follows the Firebase CLI's `firestore.indexes.json`. Unknown keys are preserved and
657
- ignored rather than rejected, so that a newer field does not break linting.
658
- - Semantic versioning. Pre-1.0, rule additions and message changes may land in minor releases;
659
- the `json` output shape is the stable contract and changes only in major releases after 1.0.
660
- - The package also exports a JavaScript API, so the rules can be run without spawning a process.
661
- That API is **provisional**: it is not part of the stable contract before 1.0 and may change in
662
- any minor release. Only the `json` output shape carries the compatibility promise.
663
- - indexwright is published as a family: `indexwright`, the linter, and — from v0.2 —
664
- `@indexwright/record`, capture and coverage. Both carry no runtime dependencies at v0.2; `check`
665
- will give the second one a Firestore client (§3). They version independently, and their version
666
- numbers are not held in step: `@indexwright/record` 0.2.0 and `indexwright` 0.2.0 coincide only
667
- because the family reached v0.2 together. When `check` lands and declares a range of
668
- `indexwright` whose `json` contract it reads, that range is what ties them, not the numbers.
669
- - The query corpus (§7) is the second stable contract, and the only one that crosses a package
670
- boundary as a file. It is versioned by its own `corpusVersion` rather than by either package's
671
- release number, so a writer and a reader agree on the integer and not on each other's versions.
672
- Everything else in `@indexwright/record` is provisional before 1.0 on the same terms as the
673
- JavaScript API above.
674
-
675
- ## 11. Toward 1.0
676
-
677
- 1.0 requires, at minimum:
678
-
679
- - Measured false-positive rates for R1 and R2 across more than one real project.
680
- - At least one adopter outside the project of origin.
681
- - A decision, informed by that data, on whether any rule should default to failing.
682
-
683
- Until then the version stays below 1.0 and the README states plainly that the rules are provisional.