11/**
2- * This tests that transaction log replay works on crash. There is a bunch of data written to the system
3- * database, so replay needs to work for harper to startup.
2+ * Upgrade compatibility tests: v4.x → v5.
3+ *
4+ * Tests transaction log replay on crash (data written to system DB before kill),
5+ * downgrade-and-re-upgrade round-trips, LMDB→RocksDB migration, and v4-specific
6+ * backward-compat sub-cases (hdb_status GTM table, clustering: config key).
7+ *
8+ * ## Version matrix
9+ *
10+ * The suite is parameterized by env vars pointing at legacy Harper installations:
11+ *
12+ * HARPER_LEGACY_VERSION_PATH — primary; maps to "the v4.x build under test" (any minor)
13+ * HARPER_LEGACY_V43_PATH — v4.3.x-specific build (for version-gated sub-tests)
14+ * HARPER_LEGACY_V44_PATH — v4.4.x-specific build
15+ * HARPER_LEGACY_V45_PATH — v4.5.x-specific build
16+ * HARPER_LEGACY_V46_PATH — v4.6.x-specific build
17+ * HARPER_LEGACY_V47_PATH — v4.7.x-specific build
18+ *
19+ * To run the matrix in CI, launch once per env var, e.g.:
20+ * HARPER_LEGACY_VERSION_PATH=/opt/harper-4.3 npm run test:integration -- upgrade
21+ * HARPER_LEGACY_VERSION_PATH=/opt/harper-4.7 npm run test:integration -- upgrade
22+ *
23+ * Sub-tests that require a specific minor may use the version-specific var instead
24+ * of the generic one, and skip when that var is absent.
425 */
526import { suite , test , before , after } from 'node:test' ;
627import {
@@ -10,9 +31,9 @@ import {
1031 type ContextWithHarper ,
1132 killHarper ,
1233} from '@harperfast/integration-testing' ;
13- import { ok , deepStrictEqual } from 'node:assert' ;
34+ import { ok , deepStrictEqual , strictEqual } from 'node:assert' ;
1435import { join } from 'node:path' ;
15- import { existsSync , readdirSync , statSync } from 'node:fs' ;
36+ import { existsSync , readdirSync , statSync , writeFileSync } from 'node:fs' ;
1637
1738const WIDGET_COUNT = 60 ;
1839const buildWidgets = ( ) =>
@@ -254,3 +275,200 @@ suite(
254275 } ) ;
255276 }
256277) ;
278+
279+ // ---------------------------------------------------------------------------
280+ // hdb_status GTM table backward-compat
281+ //
282+ // Verifies that a record written to data.hdb_status in v4 is still readable
283+ // via the v5 REST API after upgrade. Uses HARPER_LEGACY_VERSION_PATH (same
284+ // as the primary suite) and is skipped when it is absent.
285+ //
286+ // Note: hdb_status is a system-managed table; not all v4 minor versions expose
287+ // it through the public upsert API. The before() hook skips the upsert when the
288+ // table is absent (table-not-found error), and the test is skipped in that case.
289+ // ---------------------------------------------------------------------------
290+
291+ let hdbStatusSeeded = false ;
292+
293+ suite (
294+ 'v4->v5: hdb_status GTM table backward-compat' ,
295+ { skip : ! legacyPath || testsBun || process . platform === 'win32' } ,
296+ ( ctx : ContextWithHarper ) => {
297+ before ( async ( ) => {
298+ await startHarper ( ctx , {
299+ config : { } ,
300+ env : {
301+ TC_AGREEMENT : 'yes' ,
302+ REPLICATION_HOSTNAME : 'localhost' ,
303+ } ,
304+ harperBinPath : join ( legacyPath ! , 'bin' , 'harperdb.js' ) ,
305+ } ) ;
306+
307+ // Write a sentinel record to data.hdb_status in v4.
308+ // Not all v4 builds expose hdb_status via the operations API; if upsert
309+ // fails with a table-not-found-style error we note it and skip assertion below.
310+ try {
311+ await sendOperation ( ctx . harper , {
312+ operation : 'upsert' ,
313+ database : 'data' ,
314+ table : 'hdb_status' ,
315+ records : [ { id : 1 , status : 200 , message : 'ok' } ] ,
316+ } ) ;
317+ hdbStatusSeeded = true ;
318+ } catch ( err : any ) {
319+ // Table absent on this v4 minor — acceptable; the test will skip.
320+ if (
321+ ! String ( err ?. message ?? err )
322+ . toLowerCase ( )
323+ . includes ( 'not found' )
324+ )
325+ throw err ;
326+ }
327+ } ) ;
328+
329+ after ( async ( ) => {
330+ await teardownHarper ( ctx ) ;
331+ } ) ;
332+
333+ test ( 'hdb_status record is readable after upgrade to v5' , async ( t ) => {
334+ if ( ! hdbStatusSeeded ) {
335+ t . skip ( 'hdb_status table not available via operations API on this v4 build' ) ;
336+ return ;
337+ }
338+
339+ await killHarper ( ctx ) ;
340+
341+ // Start v5 on the same dataRootDir — upgrade directives run automatically.
342+ await startHarper ( ctx , { config : { } , env : { } } ) ;
343+
344+ // In v4, hdb_status lived in the data database. v5 may migrate it to system.
345+ // Try data first; fall back to system so the test covers both locations.
346+ let rows : any [ ] | null = null ;
347+ try {
348+ rows = await sendOperation ( ctx . harper , {
349+ operation : 'search_by_conditions' ,
350+ database : 'data' ,
351+ table : 'hdb_status' ,
352+ conditions : [ { attribute : 'id' , comparator : 'equals' , value : 1 } ] ,
353+ } ) ;
354+ } catch {
355+ // data.hdb_status may have been migrated to system
356+ }
357+
358+ if ( ! rows || rows . length === 0 ) {
359+ rows = await sendOperation ( ctx . harper , {
360+ operation : 'search_by_conditions' ,
361+ database : 'system' ,
362+ table : 'hdb_status' ,
363+ conditions : [ { attribute : 'id' , comparator : 'equals' , value : 1 } ] ,
364+ } ) ;
365+ }
366+
367+ ok (
368+ Array . isArray ( rows ) && rows . length === 1 ,
369+ `expected 1 hdb_status row in data or system after upgrade, got ${ JSON . stringify ( rows ) } `
370+ ) ;
371+ strictEqual ( rows ! [ 0 ] . status , 200 , 'hdb_status.status should be 200 after upgrade' ) ;
372+ strictEqual ( rows ! [ 0 ] . message , 'ok' , 'hdb_status.message should be "ok" after upgrade' ) ;
373+ } ) ;
374+ }
375+ ) ;
376+
377+ // ---------------------------------------------------------------------------
378+ // v4.3.x clustering: config key backward-compat
379+ //
380+ // v4.3.x used `clustering: { enabled: true, ... }` in harper.json. v5 renamed
381+ // this key. This test verifies that v5 either migrates the old key gracefully
382+ // or emits an actionable error — it must not crash silently.
383+ //
384+ // Requires HARPER_LEGACY_V43_PATH to point at a v4.3.x installation. Skipped
385+ // when the env var is absent so the CI matrix can omit the v4.3 slot without
386+ // breaking the suite.
387+ // ---------------------------------------------------------------------------
388+
389+ const legacyV43Path = process . env . HARPER_LEGACY_V43_PATH ;
390+
391+ suite (
392+ 'v4.3.x→v5: clustering: config key does not cause silent failure' ,
393+ { skip : ! legacyV43Path || testsBun || process . platform === 'win32' } ,
394+ ( ctx : ContextWithHarper ) => {
395+ before ( async ( ) => {
396+ // Start v4.3.x without any special config — HARPER_SET_CONFIG is a v5
397+ // mechanism that v4.3 predates, so the clustering key must be injected
398+ // by writing directly to the on-disk config file after the data dir is
399+ // created. The first startHarper call just populates ctx.harper.dataRootDir.
400+ await startHarper ( ctx , {
401+ config : { } ,
402+ env : {
403+ TC_AGREEMENT : 'yes' ,
404+ REPLICATION_HOSTNAME : 'localhost' ,
405+ } ,
406+ harperBinPath : join ( legacyV43Path ! , 'bin' , 'harperdb.js' ) ,
407+ } ) ;
408+
409+ // Seed a small table so we have something to verify survives.
410+ await sendOperation ( ctx . harper , {
411+ operation : 'create_table' ,
412+ table : 'cluster_compat_test' ,
413+ primary_key : 'id' ,
414+ attributes : [ { name : 'id' , type : 'ID' } ] ,
415+ } ) ;
416+ await sendOperation ( ctx . harper , {
417+ operation : 'upsert' ,
418+ table : 'cluster_compat_test' ,
419+ records : [ { id : 'sentinel' } ] ,
420+ } ) ;
421+
422+ // Write the old-style clustering: config key directly into the on-disk
423+ // config file so that v5 reads it on startup (not v4 — v4 is already up).
424+ // v4.3 used harperdb-config.yaml; fall back to harper-config.yaml if absent.
425+ const legacyConfigPath = join ( ctx . harper . dataRootDir , 'harperdb-config.yaml' ) ;
426+ const newConfigPath = join ( ctx . harper . dataRootDir , 'harper-config.yaml' ) ;
427+ const configPath = existsSync ( legacyConfigPath ) ? legacyConfigPath : newConfigPath ;
428+
429+ // Append the old-style clustering block to the existing config file.
430+ // YAML block append: a trailing newline then a top-level clustering key.
431+ const clusteringYaml = [
432+ '' ,
433+ '# v4.3 legacy clustering key — injected by upgrade compat test' ,
434+ 'clustering:' ,
435+ ' enabled: true' ,
436+ ' nodeName: test-node' ,
437+ ' server:' ,
438+ ' port: 12345' ,
439+ '' ,
440+ ] . join ( '\n' ) ;
441+ writeFileSync ( configPath , clusteringYaml , { flag : 'a' } ) ;
442+ } ) ;
443+
444+ after ( async ( ) => {
445+ await teardownHarper ( ctx ) ;
446+ } ) ;
447+
448+ test ( 'v5 starts successfully despite old clustering: config key' , async ( ) => {
449+ await killHarper ( ctx ) ;
450+
451+ // startHarper throws HarperStartupError on non-zero exit or timeout.
452+ // A successful return here means Harper started and is ready — no crash.
453+ await startHarper ( ctx , { config : { } , env : { } } ) ;
454+
455+ ok (
456+ ctx . harper . process . exitCode === null ,
457+ 'v5 Harper process must still be running after startup with old clustering config'
458+ ) ;
459+
460+ // Optionally verify seeded data survived: checks data integrity not just
461+ // startup, but a silent partial-boot (ready signal before tables open)
462+ // would be caught here.
463+ const rows = await sendOperation ( ctx . harper , {
464+ operation : 'search_by_conditions' ,
465+ table : 'cluster_compat_test' ,
466+ conditions : [ { attribute : 'id' , comparator : 'equals' , value : 'sentinel' } ] ,
467+ } ) ;
468+ ok (
469+ Array . isArray ( rows ) && rows . length === 1 ,
470+ `expected sentinel row to survive upgrade, got ${ JSON . stringify ( rows ) } `
471+ ) ;
472+ } ) ;
473+ }
474+ ) ;
0 commit comments