@adhd/backlog 1.0.3 → 1.0.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhd/backlog",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "bin": {
5
5
  "adhd-backlog": "index.js"
6
6
  },
package/query/query.d.ts CHANGED
@@ -48,15 +48,18 @@ export interface IQueryStoreHandle {
48
48
  */
49
49
  readonly assertVocabulary?: () => Promise<void>;
50
50
  /**
51
- * Fail-loud status/priority catalog invariant guard
52
- * (`store/catalog-invariant-guard.ts`). When present, `queryIssuesWithMeta`
53
- * awaits it alongside {@link IQueryStoreHandle.assertVocabulary} before
54
- * dispatching any view: a catalog carrying an unflagged reserved terminal
55
- * status, or two same-kind rows sharing a case fold, must fail loudly rather
56
- * than serve a mis-classified read. OPTIONAL so a hand-built handle (tests,
57
- * the ETL) is unaffected — `api.ts`'s `queryHandle` wires it for every real
58
- * host, and it is deliberately re-run per query (not latched at open), like
59
- * the vocabulary guard.
51
+ * Status/priority catalog invariant check (`store/catalog-invariant-guard.ts`),
52
+ * exposed OPT-IN — deliberately NOT consulted by `queryIssuesWithMeta`, and
53
+ * therefore NEVER on the ordinary read path. A drifted catalog (an unflagged
54
+ * reserved terminal status, or two same-kind rows sharing a case fold) is a
55
+ * bounded data problem: the read path serves the store regardless, and the
56
+ * drift is surfaced as a NAMED, non-zero check by the `store-check` CLI verb.
57
+ * `api.ts`'s `queryHandle` still wires it for every real host, so a caller
58
+ * that WANTS to assert explicitly can call `handle.assertCatalogInvariants?.()`
59
+ * — but no read ever does so implicitly. (Earlier this was awaited inside
60
+ * `queryIssuesWithMeta`; that abort turned one drifted row into a total read
61
+ * outage and was removed. The write path's per-verb abort was removed the
62
+ * same way, so neither ordinary path is gated.)
60
63
  */
61
64
  readonly assertCatalogInvariants?: () => Promise<void>;
62
65
  }
@@ -9,12 +9,39 @@ export interface IResolvedRef {
9
9
  name: string;
10
10
  record: NodeRecord;
11
11
  }
12
+ /**
13
+ * Resolve `ref` to exactly one LIVE node, by exact uid or by a UNIQUE uid
14
+ * prefix. Exact match is the fast path and always wins.
15
+ *
16
+ * Errors: `AmbiguousReferenceError` when a prefix matches ≥2 live nodes (the
17
+ * candidates are carried on the error and named in its message);
18
+ * `IssueNotFoundError` (kind `issue`) / `CatalogNotFoundError` (any other
19
+ * kind) when an exact uid or prefix matches nothing; `InvalidArgumentError`
20
+ * when `ref` is a uid attempt shorter than the minimum prefix length.
21
+ */
22
+ export declare function resolveUidPrefix(graph: GraphBackend, ref: string, opts?: {
23
+ expectedKind?: string;
24
+ }): Promise<NodeRecord>;
25
+ /**
26
+ * Like {@link resolveUidPrefix}, but `null` instead of a not-found error when
27
+ * nothing matches — for callers that treat a uid miss as "fall through to a
28
+ * name lookup." An AMBIGUOUS prefix still throws (never silently picks one),
29
+ * and a too-short uid attempt returns `null` so a genuine short business name
30
+ * is not swallowed.
31
+ */
32
+ export declare function tryResolveUidPrefix(graph: GraphBackend, ref: string, opts?: {
33
+ expectedKind?: string;
34
+ }): Promise<NodeRecord | null>;
12
35
  /**
13
36
  * Resolve `uid` → the live `issue` node, or throw {@link IssueNotFoundError}
14
37
  * (SPEC.md §6.1: "a `uid` with no matching live node throws
15
38
  * `IssueNotFoundError(uid)`"). This is the READ-PATH counterpart of
16
39
  * `write/tx.ts`'s `getNodeByUidTx` — safe to call standalone because it is
17
40
  * not composing a check-then-act write around the result.
41
+ *
42
+ * Accepts an exact uid or a UNIQUE uid prefix (see {@link resolveUidPrefix});
43
+ * a superseded node — whether reached by exact uid or prefix — throws the same
44
+ * {@link StaleSupersedeError} pointing at the chain head.
18
45
  */
19
46
  export declare function resolveIssueByUid(graph: GraphBackend, uid: string): Promise<NodeRecord>;
20
47
  /**
@@ -53,10 +53,16 @@ export declare function inspectCatalogInvariants(adapter: StoreAdapter): Promise
53
53
  * Throws {@link CatalogInvariantError}, naming every offending row and the
54
54
  * repair that resolves it, otherwise.
55
55
  *
56
- * BOUNDED BY CONSTRUCTION: this runs on every write verb (`api.ts`'s
57
- * `writeHandle`), every query (`api.ts`'s `queryHandle` → `query.ts`), and
58
- * store open (`openGraphBacklogStore`), so it must not scan the store per
59
- * call. It reads only the (small) status/priority catalog — see
56
+ * WHERE IT RUNS: the `store-check` CLI verb — a NAMED, non-zero check an
57
+ * operator runs deliberately — plus any caller that asserts deliberately via
58
+ * `IQueryStoreHandle.assertCatalogInvariants`. It is deliberately NOT run on
59
+ * the ordinary read path (`queryIssuesWithMeta`), on the write path (`api.ts`'s
60
+ * `writeHandle`), or at store open (`openGraphBacklogStore`): a violation must
61
+ * be detected loudly and REPORTED, never allowed to turn every read — or every
62
+ * unrelated write — into an outage.
63
+ *
64
+ * BOUNDED BY CONSTRUCTION: it must not scan the issue graph per call. It reads
65
+ * only the (small) status/priority catalog — see
60
66
  * {@link inspectCatalogInvariants}.
61
67
  */
62
68
  export declare function assertCatalogInvariants(adapter: StoreAdapter): Promise<void>;
@@ -1,9 +1,16 @@
1
1
  import { IComponentSummary, ILocationSummary, ILocationType, IProjectSummary } from '../query/types.js';
2
2
  import { IEdgeKindRule, IWriteStoreHandle } from './tx.js';
3
+ import { isUidShaped } from './uid-prefix.js';
3
4
  import { AdapterTransaction } from '@adhd/sox-store-adapter';
4
5
 
5
- /** Whether `ref` should be treated as a `uid` (exact resolve-or-throw) rather than a business `name` (find, and for the flat catalogs, find-then-mint). */
6
- export declare function isUidShaped(ref: string): boolean;
6
+ /**
7
+ * Disambiguation by SHAPE, not a second field (§6.1): a 36-character
8
+ * version-4-UUID-formatted string is a `uid`; anything else is a `name`. The
9
+ * single definition lives in `write/uid-prefix.ts` (which also owns prefix
10
+ * classification); re-exported here so every existing `isUidShaped` importer
11
+ * keeps its path unchanged.
12
+ */
13
+ export { isUidShaped };
7
14
  export interface IResolvedCatalogRow {
8
15
  rowid: number;
9
16
  uid: string;
@@ -73,21 +80,32 @@ export interface IMintOrResolveInput {
73
80
  * that reads as non-terminal — the drift `catalog-repair.ts` exists to clean
74
81
  * up is not regenerated on the next mint.
75
82
  *
76
- * Membership is EXACT and case-sensitive — the same predicate the `(kind,name)`
77
- * lookup uses (`WHERE name = ?`). A case-variant (`Closed`) is deliberately
78
- * NOT a member: it is an exact miss, self-heals into its own row (never a
79
- * throw), and is left to the separately-owned case-fragment repair, which
80
- * folds names. Spelling the canonical rows here is what pins them; there is
81
- * exactly ONE such table in the codebase (`catalog-repair.ts` re-exports
82
- * this set rather than declaring its own).
83
+ * Membership is decided on the FOLDED name ({@link catalogNameFold}) — the
84
+ * same Unicode fold the duplicate guard and the case-fragment repair use, never
85
+ * SQL `lower()`/`NOCASE`, which fold only ASCII `A`–`Z`. The spellings below
86
+ * are the canonical catalog rows, but the PREDICATE folds, so `Closed`,
87
+ * `fixed`, `resolved` and `done` all count as reserved. This matters because a
88
+ * first-ever lowercase `fixed`/`resolved`/`done` has no uppercase row to
89
+ * collide against; under an exact-case predicate it would seed
90
+ * `terminal:false` — a closed status the read layer returns as open (backlog
91
+ * b4525bc3 / d7ec2c50). There is exactly ONE such table in the codebase
92
+ * (`catalog-repair.ts` re-exports this set rather than declaring its own).
83
93
  */
84
94
  export declare const RESERVED_TERMINAL_STATUS_NAMES: ReadonlySet<string>;
85
- /** Whether `name` is EXACTLY one of {@link RESERVED_TERMINAL_STATUS_NAMES} — case-sensitive, the same predicate the `(kind,name)` resolve uses. */
95
+ /** Whether `name` is one of {@link RESERVED_TERMINAL_STATUS_NAMES} under the case fold — so any spelling of the reserved terminal vocabulary (`closed`/`Closed`/`CLOSED`, `fixed`/`FIXED`) counts. */
86
96
  export declare function isReservedTerminalStatusName(name: string): boolean;
87
97
  /**
88
98
  * The flat-catalog find-then-create (§1, §4c, §6.1): `kind`/`status`/
89
99
  * `priority`/`agent` are mintable on an unresolved NAME; a uid-shaped `ref`
90
100
  * that does not resolve instead throws — minting NEVER applies to a uid.
101
+ *
102
+ * A NAME that case-folds to an EXISTING LIVE row of the same kind but is
103
+ * spelled differently (`IN_PROGRESS` when `in_progress` is live) is REFUSED
104
+ * with {@link CaseVariantNameError} — never fold-resolved onto the existing
105
+ * row, never minted as a twin. Identity is exact-case (`WHERE name = ?`), so a
106
+ * variant is neither the same row nor a genuinely new name; it is ambiguous,
107
+ * and the ambiguity is stopped here rather than allowed to grow a duplicate
108
+ * the catalog-invariant guard would then abort every read over.
91
109
  */
92
110
  export declare function mintOrResolveCatalogTx(tx: AdapterTransaction, input: IMintOrResolveInput): Promise<IResolvedCatalogRow>;
93
111
  /**
@@ -108,9 +126,10 @@ export declare function mintOrResolveCatalogTx(tx: AdapterTransaction, input: IM
108
126
  * exact `(kind,name)` lookup inside {@link mintOrResolveCatalogTx} finds the
109
127
  * seeded row on every subsequent call, so reseeding never duplicates — the
110
128
  * store converges to one live `status` row per name. A case-variant name
111
- * (`Closed` vs a seeded `closed`) is an exact miss, so it self-heals into its
112
- * own row rather than throwing — the pre-existing resolution property, not a
113
- * terminality decision (the case-fragment repair folds those separately).
129
+ * (`Closed` vs a seeded `closed`) is REFUSED by {@link mintOrResolveCatalogTx}
130
+ * with {@link CaseVariantNameError} rather than folded onto the existing row or
131
+ * minted as a twin — the same exact-case stop every other flat catalog takes
132
+ * (see that function's doc comment).
114
133
  *
115
134
  * A uid-shaped `ref` that does not resolve still throws
116
135
  * `CatalogNotFoundError` — minting never applies to a uid (§6.1).
package/write/errors.d.ts CHANGED
@@ -18,7 +18,7 @@
18
18
  * All named classes here are `E_VALIDATION`- or `E_CONSTRAINT`-class
19
19
  * members of the same union (SPEC.md §4c, "Two failure-signaling shapes,
20
20
  * reconciled into one contract") — `IssueNotFoundError`, `InvalidArgumentError`,
21
- * `CatalogNotFoundError`, `ClaimHeldError`, `SingleValuedRelationConflictError`,
21
+ * `CatalogNotFoundError`, `CaseVariantNameError`, `ClaimHeldError`, `SingleValuedRelationConflictError`,
22
22
  * `NoteRequiredError`, `CitationRequiredError`, `CitationUnverifiableError` are
23
23
  * `E_VALIDATION` (never retryable — thrown before any driver call runs);
24
24
  * `StaleSupersedeError` is the one deliberate `E_CONSTRAINT` this spec's own
@@ -162,6 +162,71 @@ export declare class CatalogNotFoundError extends BacklogWriteError {
162
162
  readonly retryable = false;
163
163
  constructor(catalogKind: string, ref: string);
164
164
  }
165
+ /**
166
+ * A uid reference matched MORE THAN ONE live node by prefix. Carries the
167
+ * candidate set (`uid` + `kind` + `name`) as structured data so a caller can
168
+ * disambiguate and re-run, and renders every candidate into the message.
169
+ *
170
+ * The resolver NEVER auto-selects among the candidates — silently returning
171
+ * the wrong item is strictly worse than refusing. Distinct from
172
+ * `IssueNotFoundError` (an exact uid that is genuinely absent) so a caller can
173
+ * branch structurally: an ambiguous reference is actionable (re-run with a
174
+ * longer prefix or the full uid), a missing one is not.
175
+ *
176
+ * `E_VALIDATION`, never retryable — retrying the identical short reference
177
+ * resolves to the identical candidate set.
178
+ */
179
+ export declare class AmbiguousReferenceError extends BacklogWriteError {
180
+ readonly ref: string;
181
+ readonly candidates: ReadonlyArray<{
182
+ uid: string;
183
+ kind: string;
184
+ name: string;
185
+ }>;
186
+ readonly code: "E_VALIDATION";
187
+ readonly retryable = false;
188
+ constructor(ref: string, candidates: ReadonlyArray<{
189
+ uid: string;
190
+ kind: string;
191
+ name: string;
192
+ }>);
193
+ }
194
+ /**
195
+ * A flat-catalog write named a token that differs from an EXISTING LIVE row of
196
+ * the SAME kind only by letter case (`IN_PROGRESS` vs `in_progress`, `high` vs
197
+ * `HIGH`). Catalog identity is EXACT-case (`DATA_MODEL.md:70-78`; the
198
+ * `(kind, name)` resolve in `write/catalog.ts` is `WHERE name = ?`), so the
199
+ * write path REFUSES the variant rather than fold-resolving it onto the
200
+ * existing row or minting a twin. Accepting it would grow a second live row
201
+ * that case-folds to the first — the case-fragment defect the uniqueness guard
202
+ * (`store/catalog-invariant-guard.ts`) exists to catch, which is exactly how
203
+ * the store wedged (BUG: a mixed-case write minted a twin, then the guard
204
+ * refused every read).
205
+ *
206
+ * The message names BOTH spellings and tells the caller which one to use: the
207
+ * one already live. The ambiguity is stopped here, at the single write call
208
+ * site, instead of aborting every read.
209
+ *
210
+ * `E_VALIDATION`, never retryable — this is a before-any-mint decision, so
211
+ * retrying the identical payload fails identically.
212
+ */
213
+ export declare class CaseVariantNameError extends BacklogWriteError {
214
+ /** The flat-catalog kind (`status`, `priority`, `kind`, `agent`). */
215
+ readonly catalogKind: string;
216
+ /** The name of the row ALREADY LIVE for this kind — the spelling to use. */
217
+ readonly canonicalName: string;
218
+ /** The differing name the caller supplied — the spelling that was refused. */
219
+ readonly offendingName: string;
220
+ readonly code: "E_VALIDATION";
221
+ readonly retryable = false;
222
+ constructor(
223
+ /** The flat-catalog kind (`status`, `priority`, `kind`, `agent`). */
224
+ catalogKind: string,
225
+ /** The name of the row ALREADY LIVE for this kind — the spelling to use. */
226
+ canonicalName: string,
227
+ /** The differing name the caller supplied — the spelling that was refused. */
228
+ offendingName: string);
229
+ }
165
230
  /** A caller-supplied argument is missing, blank, or fails a project-declared invariant. */
166
231
  export declare class InvalidArgumentError extends BacklogWriteError {
167
232
  readonly field: string;
@@ -172,9 +237,16 @@ export declare class InvalidArgumentError extends BacklogWriteError {
172
237
  /** No live `issue` node carries the given `uid` (SPEC.md §6.1). */
173
238
  export declare class IssueNotFoundError extends BacklogWriteError {
174
239
  readonly uid: string;
240
+ readonly asPrefix: boolean;
175
241
  readonly code: "E_VALIDATION";
176
242
  readonly retryable = false;
177
- constructor(uid: string);
243
+ /**
244
+ * @param uid The uid (or uid prefix) the caller passed in.
245
+ * @param asPrefix Set by the prefix resolver when `uid` was a short prefix
246
+ * rather than a full uid, so the message says "no item matches" instead of
247
+ * implying the caller passed a complete uid that is simply absent.
248
+ */
249
+ constructor(uid: string, asPrefix?: boolean);
178
250
  }
179
251
  /**
180
252
  * `claim` (§6.3.5): the lease is held by someone else, not yet stale, and the
package/write/tx.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { INodeReadExecutor } from './uid-prefix.js';
1
2
  import { BacklogWriteError } from './errors.js';
2
3
  import { TypePolicy } from '@adhd/sox-graph-store';
3
4
  import { AdapterTransaction, StoreAdapter } from '@adhd/sox-store-adapter';
@@ -98,7 +99,28 @@ export declare function canonicalJSONStringify(value: Record<string, unknown>):
98
99
  * issued against `tx` instead of the bare adapter — never a `getNodeByUid`
99
100
  * call itself, which always runs against `this.adapter` (§4c).
100
101
  */
101
- export declare function getNodeByUidTx(tx: AdapterTransaction, uid: string): Promise<ITxNodeRow | null>;
102
+ export declare function getNodeByUidTx(tx: INodeReadExecutor, uid: string): Promise<ITxNodeRow | null>;
103
+ /**
104
+ * uid reference → LIVE `issue`/catalog node row, inside a tx, accepting a
105
+ * UNIQUE uid PREFIX as well as an exact uid. This is the single write-side
106
+ * funnel every issue verb ({@link resolveLiveIssueTx}) and catalog
107
+ * uid-resolution (`catalog.ts`'s `resolveByUidTx`, hence `rm-location`) goes
108
+ * through, so the prefix contract is defined once rather than per verb.
109
+ *
110
+ * Exact match is the fast path and keeps winning: a full uid is looked up
111
+ * directly, and only when that misses is a prefix scan attempted. A prefix
112
+ * below `MIN_UID_PREFIX_LENGTH` throws an "too short" argument error; a prefix
113
+ * that matches zero rows throws the kind-appropriate not-found; a prefix that
114
+ * matches two or more throws {@link AmbiguousReferenceError} naming every
115
+ * candidate — never an arbitrary pick.
116
+ *
117
+ * @throws IssueNotFoundError (kind `issue`) / CatalogNotFoundError (any other
118
+ * kind) when nothing live matches.
119
+ * @throws AmbiguousReferenceError when a prefix matches more than one live row.
120
+ */
121
+ export declare function resolveUidPrefixTx(exec: INodeReadExecutor, ref: string, opts?: {
122
+ expectedKind?: string;
123
+ }): Promise<ITxNodeRow>;
102
124
  /**
103
125
  * Resolves `uid` to the CURRENT, live `issue` row, or throws.
104
126
  *
@@ -0,0 +1,78 @@
1
+ import { InvalidArgumentError } from './errors.js';
2
+
3
+ /** Whether `ref` is an exact, full uid (resolve-or-throw), never a business `name` and never a prefix. */
4
+ export declare function isUidShaped(ref: string): boolean;
5
+ /**
6
+ * The shortest uid prefix this resolver will accept. Eight hex characters —
7
+ * the first UUID block, the human-copyable unit, and 32 bits of address space;
8
+ * see this file's header for the full justification.
9
+ */
10
+ export declare const MIN_UID_PREFIX_LENGTH = 8;
11
+ /**
12
+ * Whether `ref` is a proper prefix (length `>=` {@link MIN_UID_PREFIX_LENGTH},
13
+ * `< 36`) of some canonical uid string — hex characters with hyphens only at
14
+ * the canonical boundary positions.
15
+ */
16
+ export declare function isUidPrefixShaped(ref: string): boolean;
17
+ /** How a caller-supplied reference reads: an exact uid, a uid prefix, a too-short uid attempt, or not uid-shaped at all. */
18
+ export type UidRefKind = 'exact' | 'prefix' | 'too-short' | 'not-uid';
19
+ /**
20
+ * Classify `ref` for uid resolution. `too-short` is a uid ATTEMPT (hex/hyphen
21
+ * only) below {@link MIN_UID_PREFIX_LENGTH} — a strict uid context rejects it
22
+ * as too short, while a uid-or-name context falls back to a name lookup, so a
23
+ * genuine 6-character value that is itself a valid name is never swallowed.
24
+ */
25
+ export declare function classifyUidRef(ref: string): UidRefKind;
26
+ /** One live node matching a uid PREFIX, projected to the fields the ambiguity decision and its error need. */
27
+ export interface IUidCandidate {
28
+ uid: string;
29
+ kind: string;
30
+ name: string | null;
31
+ isSuperseded: boolean;
32
+ }
33
+ /**
34
+ * The minimal executor a prefix candidate read needs. Structural, so it is
35
+ * satisfied by `AdapterTransaction`, graph-store's `GraphTransaction`, and a
36
+ * bare `StoreAdapter` alike — nothing here depends on which one it is.
37
+ */
38
+ export interface IUidPrefixQueryExecutor {
39
+ executeAll<T = Record<string, unknown>>(sql: string, args?: unknown[]): Promise<{
40
+ rows: T[];
41
+ }>;
42
+ }
43
+ /**
44
+ * The executor a uid resolution needs: the prefix candidate read plus the
45
+ * exact single-row read. Structural, so `AdapterTransaction`,
46
+ * graph-store's `GraphTransaction`, and a bare `StoreAdapter` all satisfy it.
47
+ */
48
+ export interface INodeReadExecutor extends IUidPrefixQueryExecutor {
49
+ executeGet<T = Record<string, unknown>>(sql: string, args?: unknown[]): Promise<T | null>;
50
+ }
51
+ /**
52
+ * Every LIVE node whose uid starts with `prefix`.
53
+ *
54
+ * The range predicate `uid >= ? AND uid < ?` is an indexed prefix scan (the
55
+ * store keeps a unique index on `uid`) and, unlike a `LIKE` pattern, is
56
+ * case-exact against the lowercase uids the store mints — so the input is
57
+ * lower-cased once here and the caller never has to reason about collation.
58
+ * Soft-deleted rows are excluded in SQL (`t_invalid IS NULL`); a superseded
59
+ * row is returned (its `is_superseded` flag carried) so a caller that resolves
60
+ * to it can raise the same stale-reference error the exact path does.
61
+ */
62
+ export declare function queryLiveUidPrefixCandidates(exec: IUidPrefixQueryExecutor, prefix: string): Promise<IUidCandidate[]>;
63
+ /**
64
+ * The ambiguity decision, shared by both paths. Returns the sole candidate, or
65
+ * `null` when there are none (the caller owns the not-found error, which
66
+ * differs by kind). Two or more candidates throw {@link AmbiguousReferenceError}
67
+ * — the resolver never auto-selects.
68
+ */
69
+ export declare function selectUniqueUidCandidate(ref: string, candidates: readonly IUidCandidate[]): IUidCandidate | null;
70
+ /**
71
+ * The not-found error for a uid reference, keyed by the kind the caller asked
72
+ * for. `asPrefix` selects the prefix-specific wording ("no item matches")
73
+ * versus the exact-uid wording, so a caller can tell "this short reference
74
+ * matched nothing" from "this full uid is not here."
75
+ */
76
+ export declare function missingUidError(expectedKind: string | undefined, ref: string, asPrefix: boolean): Error;
77
+ /** The too-short refusal — a uid attempt below {@link MIN_UID_PREFIX_LENGTH}. */
78
+ export declare function tooShortUidError(ref: string): InvalidArgumentError;