fhir-persistence 0.4.0 → 0.6.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,6 +5,56 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.6.0] - 2025-03-17
9
+
10
+ ### Added
11
+
12
+ #### Full-Text Search (SQLite FTS5 + PostgreSQL tsvector/GIN)
13
+
14
+ - **`table-schema-builder.ts`** — HumanName and Address lookup tables now include tsvector GIN expression indexes (`to_tsvector('simple'::regconfig, column)`) for PostgreSQL full-text search
15
+ - **`where-builder.ts`** — Lookup table string search supports FTS query paths: SQLite FTS5 MATCH and PostgreSQL `to_tsvector @@ plainto_tsquery` with automatic fallback to LIKE
16
+ - **`fhir-system.ts`** — `FhirSystemConfig.features.fullTextSearch` option to enable FTS query paths (default: `false` for backward compatibility)
17
+ - SQLite FTS5 virtual tables generated via `MigrationGenerator` for HumanName/Address lookup columns
18
+
19
+ #### Reindex Progress Callbacks
20
+
21
+ - **`cli/reindex.ts`** — `ReindexProgressCallbackV2` type with `onProgress` callback reporting `{ resourceType, processed, total }` per batch
22
+ - **`reindexResourceTypeV2`** and **`reindexAllV2`** accept optional `onProgress` parameter for CLI and UI progress display
23
+
24
+ #### Conditional Operations API
25
+
26
+ - **`store/conditional-service.ts`** — `ConditionalService` class with full FHIR R4 conditional semantics:
27
+ - `conditionalCreate`: 0 match → create, 1 match → return existing, 2+ → `PreconditionFailedError`
28
+ - `conditionalUpdate`: 0 match → create, 1 match → update, 2+ → `PreconditionFailedError`
29
+ - `conditionalDelete`: delete all matching resources, return count
30
+ - **`repo/errors.ts`** — `PreconditionFailedError` (HTTP 412) for multiple-match conditional operations
31
+ - All conditional operations execute within transactions (TOCTOU protection)
32
+
33
+ ### Changed
34
+
35
+ - **`table-schema-builder.ts`** — HumanName lookup table adds `pg_trgm` GIN indexes (`gin_trgm_ops`) alongside tsvector indexes for trigram fuzzy matching
36
+ - **`migration-generator.ts`** — Automatically creates `pg_trgm` and `btree_gin` extensions on PostgreSQL before GIN index generation
37
+
38
+ ## [0.5.0] - 2025-03-16
39
+
40
+ ### Fixed
41
+
42
+ #### PostgreSQL Migration Path Fixes
43
+
44
+ - **`migration-generator.ts`** — IG migration path now creates PostgreSQL extensions (`pg_trgm`, `btree_gin`) and helper function (`token_array_to_text`) before generating GIN indexes, fixing "no default operator class for access method gin" error
45
+ - **`where-builder.ts`** — Lookup table EXISTS subqueries now use fully-qualified `"ResourceType"."id"` instead of ambiguous `"id"`, fixing PostgreSQL "operator does not exist: text = integer" error in name/address/telecom searches
46
+
47
+ ### Changed
48
+
49
+ - **`buildWhereFragment` (v1)** — Added optional `resourceType` parameter, passed to `buildLookupTableFragment`
50
+ - **`buildWhereFragmentV2` (v2)** — Added optional `resourceType` parameter, passed to `buildLookupTableFragmentV2`
51
+ - Both `buildLookupTableFragment*` functions now generate `outerIdRef = "ResourceType"."id"` to eliminate column name ambiguity in PostgreSQL
52
+
53
+ ### Test Coverage
54
+
55
+ - **1014 total tests** (1006 passing, 8 skipped) across 56 test files — no regressions
56
+ - Updated 7 lookup table test assertions to use qualified column references
57
+
8
58
  ## [0.4.0] - 2025-03-15
9
59
 
10
60
  ### Fixed
package/README.md CHANGED
@@ -5,7 +5,7 @@ Embedded FHIR R4 persistence layer — CRUD, search, indexing, and schema migrat
5
5
  [![npm version](https://img.shields.io/npm/v/fhir-persistence)](https://www.npmjs.com/package/fhir-persistence)
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](./LICENSE)
7
7
 
8
- > **v0.4.0** — Full PostgreSQL DDL compatibility for all system and lookup tables
8
+ > **v0.6.0** — Full-text search (SQLite FTS5 + PostgreSQL tsvector/GIN), reindex progress callbacks, conditional operations API
9
9
 
10
10
  ## Features
11
11
 
@@ -21,7 +21,9 @@ Embedded FHIR R4 persistence layer — CRUD, search, indexing, and schema migrat
21
21
  - **Two-phase SQL** — id-first query for large table performance
22
22
  - **IG-driven schema** — StructureDefinition + SearchParameter → DDL (SQLite + PostgreSQL dialects)
23
23
  - **Migration engine** — SchemaDiff → MigrationGenerator → MigrationRunnerV2
24
- - **Conditional operations** — conditionalCreate / conditionalUpdate / conditionalDelete
24
+ - **Full-text search** — SQLite FTS5 + PostgreSQL tsvector/GIN for string search parameters
25
+ - **Conditional operations** — conditionalCreate / conditionalUpdate / conditionalDelete via `ConditionalService`
26
+ - **Reindex progress** — `onProgress` callback for CLI and UI progress reporting
25
27
  - **Bundle processing** — transaction and batch bundle support
26
28
  - **Terminology** — TerminologyCodeRepo + ValueSetRepo
27
29
  - **FhirSystem orchestrator** — end-to-end startup flow for `fhir-engine` integration
@@ -5202,12 +5202,12 @@ function arrayContainsLikeV2(col, value, dialect) {
5202
5202
  }
5203
5203
  return { sql: `EXISTS (SELECT 1 FROM json_each(${col}) WHERE json_each.value LIKE ?)`, values: [value] };
5204
5204
  }
5205
- function buildWhereFragmentV2(impl, param, dialect) {
5205
+ function buildWhereFragmentV2(impl, param, dialect, resourceType) {
5206
5206
  if (param.modifier === "missing") {
5207
5207
  return buildMissingFragmentV2(impl, param);
5208
5208
  }
5209
5209
  if (impl.strategy === "lookup-table") {
5210
- return buildLookupTableFragmentV2(impl, param);
5210
+ return buildLookupTableFragmentV2(impl, param, resourceType ?? "Resource");
5211
5211
  }
5212
5212
  if (impl.strategy === "token-column") {
5213
5213
  return buildTokenColumnFragmentV2(impl, param, dialect);
@@ -5235,7 +5235,7 @@ function buildMissingFragmentV2(impl, param) {
5235
5235
  const col = quoteColumn(impl.columnName);
5236
5236
  return { sql: isMissing ? `${col} IS NULL` : `${col} IS NOT NULL`, values: [] };
5237
5237
  }
5238
- function buildLookupTableFragmentV2(impl, param) {
5238
+ function buildLookupTableFragmentV2(impl, param, resourceType) {
5239
5239
  const mapping = LOOKUP_TABLE_MAP[impl.code];
5240
5240
  if (!mapping) {
5241
5241
  const sortCol = quoteColumn(`__${impl.columnName}Sort`);
@@ -5245,25 +5245,26 @@ function buildLookupTableFragmentV2(impl, param) {
5245
5245
  }
5246
5246
  const { table, column } = mapping;
5247
5247
  const colRef = `__lookup."${column}"`;
5248
+ const outerIdRef = `"${resourceType}"."id"`;
5248
5249
  if (param.modifier === "exact") {
5249
5250
  if (param.values.length === 1) {
5250
- return { sql: `EXISTS (SELECT 1 FROM "${table}" __lookup WHERE __lookup."resourceId" = "id" AND ${colRef} = ?)`, values: [param.values[0]] };
5251
+ return { sql: `EXISTS (SELECT 1 FROM "${table}" __lookup WHERE __lookup."resourceId" = ${outerIdRef} AND ${colRef} = ?)`, values: [param.values[0]] };
5251
5252
  }
5252
5253
  const conds2 = param.values.map(() => `${colRef} = ?`);
5253
- return { sql: `EXISTS (SELECT 1 FROM "${table}" __lookup WHERE __lookup."resourceId" = "id" AND (${conds2.join(" OR ")}))`, values: [...param.values] };
5254
+ return { sql: `EXISTS (SELECT 1 FROM "${table}" __lookup WHERE __lookup."resourceId" = ${outerIdRef} AND (${conds2.join(" OR ")}))`, values: [...param.values] };
5254
5255
  }
5255
5256
  if (param.modifier === "contains") {
5256
5257
  if (param.values.length === 1) {
5257
- return { sql: `EXISTS (SELECT 1 FROM "${table}" __lookup WHERE __lookup."resourceId" = "id" AND LOWER(${colRef}) LIKE ?)`, values: [`%${param.values[0].toLowerCase()}%`] };
5258
+ return { sql: `EXISTS (SELECT 1 FROM "${table}" __lookup WHERE __lookup."resourceId" = ${outerIdRef} AND LOWER(${colRef}) LIKE ?)`, values: [`%${param.values[0].toLowerCase()}%`] };
5258
5259
  }
5259
5260
  const conds2 = param.values.map(() => `LOWER(${colRef}) LIKE ?`);
5260
- return { sql: `EXISTS (SELECT 1 FROM "${table}" __lookup WHERE __lookup."resourceId" = "id" AND (${conds2.join(" OR ")}))`, values: param.values.map((v) => `%${v.toLowerCase()}%`) };
5261
+ return { sql: `EXISTS (SELECT 1 FROM "${table}" __lookup WHERE __lookup."resourceId" = ${outerIdRef} AND (${conds2.join(" OR ")}))`, values: param.values.map((v) => `%${v.toLowerCase()}%`) };
5261
5262
  }
5262
5263
  if (param.values.length === 1) {
5263
- return { sql: `EXISTS (SELECT 1 FROM "${table}" __lookup WHERE __lookup."resourceId" = "id" AND LOWER(${colRef}) LIKE ?)`, values: [`${param.values[0].toLowerCase()}%`] };
5264
+ return { sql: `EXISTS (SELECT 1 FROM "${table}" __lookup WHERE __lookup."resourceId" = ${outerIdRef} AND LOWER(${colRef}) LIKE ?)`, values: [`${param.values[0].toLowerCase()}%`] };
5264
5265
  }
5265
5266
  const conds = param.values.map(() => `LOWER(${colRef}) LIKE ?`);
5266
- return { sql: `EXISTS (SELECT 1 FROM "${table}" __lookup WHERE __lookup."resourceId" = "id" AND (${conds.join(" OR ")}))`, values: param.values.map((v) => `${v.toLowerCase()}%`) };
5267
+ return { sql: `EXISTS (SELECT 1 FROM "${table}" __lookup WHERE __lookup."resourceId" = ${outerIdRef} AND (${conds.join(" OR ")}))`, values: param.values.map((v) => `${v.toLowerCase()}%`) };
5267
5268
  }
5268
5269
  function buildStringFragmentV2(impl, param) {
5269
5270
  const col = quoteColumn(impl.columnName);
@@ -5432,7 +5433,7 @@ function buildWhereClauseV2(params, registry, resourceType, dialect) {
5432
5433
  }
5433
5434
  const impl = resolveImplV2(param, registry, resourceType);
5434
5435
  if (!impl) continue;
5435
- const fragment = buildWhereFragmentV2(impl, param, dialect);
5436
+ const fragment = buildWhereFragmentV2(impl, param, dialect, resourceType);
5436
5437
  if (fragment) {
5437
5438
  fragments.push(fragment);
5438
5439
  }
@@ -7280,6 +7281,14 @@ function generateMigration(deltas, dialect) {
7280
7281
  const down = [];
7281
7282
  const reindexDeltas = [];
7282
7283
  const descriptions = [];
7284
+ const needsPgExtensions = deltas.some((d) => d.kind === "ADD_TABLE" || d.kind === "ADD_INDEX");
7285
+ if (dialect === "postgres" && needsPgExtensions) {
7286
+ up.push("CREATE EXTENSION IF NOT EXISTS pg_trgm;");
7287
+ up.push("CREATE EXTENSION IF NOT EXISTS btree_gin;");
7288
+ up.push(
7289
+ `CREATE OR REPLACE FUNCTION token_array_to_text(arr text[]) RETURNS text LANGUAGE sql IMMUTABLE AS $$ SELECT array_to_string(arr, ' ') $$;`
7290
+ );
7291
+ }
7283
7292
  for (const delta of deltas) {
7284
7293
  switch (delta.kind) {
7285
7294
  case "ADD_TABLE": {