@happyvertical/smrt-facts 0.49.3 → 0.49.4

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/README.md CHANGED
@@ -121,3 +121,125 @@ const briefing = await facts.getEntityBriefing('Place', placeId);
121
121
 
122
122
  See [`AGENTS.md`](./AGENTS.md) for package architecture, invariants, validation,
123
123
  and contributor guidance.
124
+
125
+ ## Catalog pagination
126
+
127
+ `browseCatalog(query, { limit, offset, latestOnly, tenantId })` defaults to 25
128
+ results and resolves evolution chains unless `latestOnly: false` is supplied.
129
+ Empty-query browsing and successful semantic search hydrate only the requested
130
+ SQL page. Empty-query candidate eligibility remains bounded to
131
+ `offset + 2 * limit`; resolving and deduplicating chains can therefore return a
132
+ short page. Semantic candidate retrieval remains bounded to `offset + limit`.
133
+ These SQL windows use the requested pagination values rather than the collection
134
+ `defaultListLimit`. The full tenant-visible successor set is available to chain resolution, even
135
+ when a successor lies outside the candidate window or status filter. Implicit-scope
136
+ confidence ties retain the newest successor, matching the previous ordered read.
137
+
138
+ Without an explicit tenant, the default candidate status is `active`; with an
139
+ explicit tenant, tenant and global candidates exclude only `superseded`.
140
+ `includeSuperseded` removes that status filter. Active tenant context continues
141
+ to constrain implicit reads, and requesting another tenant is rejected. STI child
142
+ collections constrain both candidates and successors to their discriminator.
143
+
144
+ Catalog reads apply normal `beforeList` authorization predicates to candidates,
145
+ readiness checks, and the complete successor graph, including empty queries.
146
+ Explicit tenant/global reads use `withTenantGlobalRead`, which grants the built-in
147
+ tenancy list hook that narrow scope while business hooks keep the original user,
148
+ permissions, tenant context, and system/non-system identity. Actual system calls
149
+ remain system calls. Interceptor rejection and application SQL failures propagate
150
+ to the caller.
151
+ Only unavailable embedding configuration or query-embedding provider failure
152
+ permits text fallback; a semantic authorization failure never does.
153
+
154
+ When query embeddings are unavailable, text fallback matches the exact JavaScript
155
+ expression `` `${textRefined} ${textRaw}`.toLowerCase().includes(query.toLowerCase()) ``.
156
+ It uses persisted `catalogSearch` storage and the same bounded SQL page traversal.
157
+ The storage encodes lowercased UTF-16 units as aligned ASCII tokens, preserving
158
+ Unicode, whitespace, literal wildcard characters, and code-unit boundaries without
159
+ SQL collation or case-folding differences. A scalar readiness check precedes text
160
+ search; it throws with backfill instructions if any permitted row is unbackfilled.
161
+ The database may scan matching rows and evolution edges internally, but only the
162
+ requested Fact page crosses the database boundary. Arbitrary substring matching
163
+ cannot use a normal B-tree index; no misleading search-column index is added.
164
+
165
+ ### Existing deployment migration
166
+
167
+ This is an explicit schema and data migration; ordinary reads never create schema.
168
+ Stop old application writers, deploy the new manifest/code, and run `smrt db:migrate`
169
+ (and `smrt db:status --parity`). This adds nullable `catalog_search`; historical
170
+ rows remain NULL. Before enabling catalog text reads, run the following with your
171
+ application's database configuration and repeat until `remaining` is zero:
172
+
173
+ ```typescript
174
+ import { FactCollection } from '@happyvertical/smrt-facts';
175
+ import { withSystemContext } from '@happyvertical/smrt-tenancy';
176
+
177
+ const facts = await FactCollection.create({ db });
178
+ await withSystemContext(async () => {
179
+ while ((await facts.backfillCatalogSearch(100)).remaining > 0) {
180
+ // Each call is independently resumable; record progress in your job runner.
181
+ }
182
+ });
183
+ ```
184
+
185
+ Backfill requires explicit system context, processes at most 100 rows per call by
186
+ default (maximum 1000), and updates only NULL values whose source texts still match
187
+ the read snapshot. It changes no source text, timestamps, embeddings, or revisions.
188
+ A crash or concurrent write is safe to retry; sustained old writers must be stopped
189
+ so the operation can finish. A subtype collection backfills only its discriminator;
190
+ use the base collection for the full deployment. Back up before schema changes;
191
+ roll forward by rerunning migration/backfill rather than dropping historical data.
192
+
193
+ `Fact.save()`, collection create/get-or-insert/get-or-upsert, and generated model
194
+ updates maintain search storage from the source fields. `catalogSearch` is derived;
195
+ callers must not author it, and generated transport surfaces exclude it using
196
+ readonly/sensitive field metadata. Derivation uses the final persistence row after
197
+ mutable `beforeSave` hooks and the complete subclass `transformJSON` chain,
198
+ keeping persisted text and search storage consistent. If a custom transform omits a source column, returns a value other than a string
199
+ or explicit NULL, or returns a string with an unpaired UTF-16 surrogate, the write
200
+ retains its ordinary adapter semantics (retention, defaults, or coercion); search storage is
201
+ invalidated to NULL rather than guessed from instance values. Catalog text reads
202
+ then fail with the readiness error until privileged bounded backfill reads the
203
+ actual persisted columns. Run that backfill after such custom writes before
204
+ resuming catalog text reads. Only well-formed strings and explicit NULL source
205
+ values are known before persistence. Query encoding remains exact UTF-16
206
+ code-unit matching, including queries containing an unpaired surrogate.
207
+ Plain/public serialization
208
+ retains the saved marker instead of recomputing from pre-transform instance text.
209
+ Direct SQL writers must set `catalog_search = NULL` whenever either source text
210
+ changes, then run backfill before text reads resume.
211
+ If a custom writer or a pre-release implementation produced a known stale
212
+ non-NULL search value, explicitly set that affected row's `catalog_search` to
213
+ NULL and run the same bounded backfill. Backfill intentionally selects NULL
214
+ markers; it does not scan or repair non-NULL values. This is a targeted repair,
215
+ not an additional step for a fresh column migration.
216
+
217
+ Do not keep old application writers active after backfill: they cannot maintain
218
+ this new invariant. Future changes to JavaScript lowercasing/encoding require an
219
+ explicit new backfill; the format is not locale dependent.
220
+
221
+ PostgreSQL and SQLite run canonical pagination integration tests. DuckDB query
222
+ coverage uses an explicitly identified SQL-only fixture: canonical Fact schema
223
+ creation currently rejects its evolution self-reference, tracked in
224
+ [#2830](https://github.com/happyvertical/smrt/issues/2830). DuckDB hydration issues
225
+ one `DESCRIBE` plus one data query per page; PostgreSQL and SQLite use one data
226
+ query after semantic candidate retrieval, if any. Text fallback also performs one
227
+ scalar readiness query.
228
+
229
+ Catalog reads preserve hydrated `afterList` policies on every final bounded page,
230
+ including empty pages, after raw-query hooks and similarity annotations. The same
231
+ list context and original caller identity reach before/after hooks. Filtering or
232
+ redaction may shorten a page; browsing does not refill it. Policy rejection
233
+ propagates and never triggers the embedding-unavailable text fallback.
234
+
235
+ Text fallback is selected by core's provider-origin availability result: only
236
+ missing embedding configuration or a failed query embedding enables it. Errors
237
+ from authorization, ranking, SQL, hydration, or result hooks propagate unchanged,
238
+ even when a caller throws `EmbeddingUnavailableError` from those operations.
239
+
240
+ Catalog text-read readiness preserves database permission, transient and unknown
241
+ errors unchanged. Only recognized missing-table/column driver diagnostics receive
242
+ the migration/backfill instruction, retaining the original error as cause.
243
+ `Fact` declares `catalog_search` as its permitted final derived column; custom
244
+ normalization must preserve that declaration and cannot replace framework
245
+ identity, tenant, revision or conflict columns.
package/dist/index.d.ts CHANGED
@@ -48,6 +48,8 @@ export declare type EvolutionType = 'original' | 'correction' | 'refinement' | '
48
48
  export declare class Fact extends SmrtObject {
49
49
  textRefined: string;
50
50
  textRaw: string;
51
+ /** Derived search storage. NULL requires the explicit catalog backfill. */
52
+ catalogSearch: string | null;
51
53
  type: string;
52
54
  status: string;
53
55
  domain: string;
@@ -68,6 +70,8 @@ export declare class Fact extends SmrtObject {
68
70
  createdAt: Date;
69
71
  updatedAt: Date;
70
72
  constructor(options?: FactOptions);
73
+ protected getPersistenceDerivedColumns(): readonly string[];
74
+ protected normalizePersistenceData(data: Readonly<Record<string, unknown>>): Record<string, unknown>;
71
75
  getMetadata(): FactMetadata;
72
76
  setMetadata(data: FactMetadata): void;
73
77
  updateMetadata(updates: Partial<FactMetadata>): void;
@@ -132,6 +136,11 @@ export declare type FactClaimSupportStatus = 'supported' | 'unsupported' | 'cont
132
136
 
133
137
  export declare class FactCollection extends SmrtCollection<Fact> {
134
138
  static readonly _itemClass: typeof Fact;
139
+ /**
140
+ * Fetch one catalog page in SQL. The recursive branch walk may inspect more
141
+ * rows inside the database, but the outer query only materializes the page.
142
+ */
143
+ private listCatalogPage;
135
144
  /**
136
145
  * Get all active facts
137
146
  */
@@ -189,6 +198,15 @@ export declare class FactCollection extends SmrtCollection<Fact> {
189
198
  includeSuperseded?: boolean;
190
199
  latestOnly?: boolean;
191
200
  }): Promise<Fact[]>;
201
+ /**
202
+ * Explicit data migration after db:migrate adds catalog_search. Run under
203
+ * withSystemContext with old writers stopped. Repeating a batch is safe;
204
+ * concurrent source changes are protected by compare-and-set predicates.
205
+ * A subtype collection backfills only its STI discriminator.
206
+ */
207
+ backfillCatalogSearch(batchSize?: number): Promise<{
208
+ remaining: number;
209
+ }>;
192
210
  /**
193
211
  * Get all facts linked to a content item.
194
212
  */