From 1e5af572b523c061d51abcb238c58bd6d0308b47 Mon Sep 17 00:00:00 2001 From: Dave Seleno <958603+onelesd@users.noreply.github.com> Date: Mon, 1 Dec 2025 19:25:49 -0800 Subject: [PATCH] feat(ts-moose-lib): add MOOSE_CLIENT_ONLY mode for Next.js HMR compatibility Enable Next.js apps to import OlapTable definitions for type-safe queries without the Moose runtime. When MOOSE_CLIENT_ONLY=true, duplicate table registrations silently overwrite instead of throwing, which prevents 'already exists' errors during Next.js HMR reloads. Closes ENG-1598 --- packages/ts-moose-lib/src/dmv2/internal.ts | 13 + .../ts-moose-lib/src/dmv2/sdk/olapTable.ts | 6 +- .../ts-moose-lib/src/dmv2/sdk/sqlResource.ts | 6 +- .../tests/client-only-mode.test.ts | 308 ++++++++++++++++++ 4 files changed, 329 insertions(+), 4 deletions(-) create mode 100644 packages/ts-moose-lib/tests/client-only-mode.test.ts diff --git a/packages/ts-moose-lib/src/dmv2/internal.ts b/packages/ts-moose-lib/src/dmv2/internal.ts index a795f1c540..245ed7dd3c 100644 --- a/packages/ts-moose-lib/src/dmv2/internal.ts +++ b/packages/ts-moose-lib/src/dmv2/internal.ts @@ -43,6 +43,19 @@ function getSourceDir(): string { return process.env.MOOSE_SOURCE_DIR || "app"; } +/** + * Client-only mode check. When true, resource registration is permissive + * (duplicates overwrite silently instead of throwing). + * Set via MOOSE_CLIENT_ONLY=true environment variable. + * + * This enables Next.js apps to import OlapTable definitions for type-safe + * queries without the Moose runtime, avoiding "already exists" errors on HMR. + * + * @returns true if MOOSE_CLIENT_ONLY environment variable is set to "true" + */ +export const isClientOnlyMode = (): boolean => + process.env.MOOSE_CLIENT_ONLY === "true"; + /** * Internal registry holding all defined Moose dmv2 resources. * Populated by the constructors of OlapTable, Stream, IngestApi, etc. diff --git a/packages/ts-moose-lib/src/dmv2/sdk/olapTable.ts b/packages/ts-moose-lib/src/dmv2/sdk/olapTable.ts index 25d564c304..5d68cc9915 100644 --- a/packages/ts-moose-lib/src/dmv2/sdk/olapTable.ts +++ b/packages/ts-moose-lib/src/dmv2/sdk/olapTable.ts @@ -6,7 +6,7 @@ import { isNestedType, } from "../../dataModels/dataModelTypes"; import { ClickHouseEngines } from "../../blocks/helpers"; -import { getMooseInternal } from "../internal"; +import { getMooseInternal, isClientOnlyMode } from "../internal"; import { Readable } from "node:stream"; import { createHash } from "node:crypto"; import type { @@ -610,7 +610,9 @@ export class OlapTable extends TypedBase> { const tables = getMooseInternal().tables; const registryKey = this.config.version ? `${name}_${this.config.version}` : name; - if (tables.has(registryKey)) { + // In client-only mode (MOOSE_CLIENT_ONLY=true), allow duplicate registrations + // to support Next.js HMR which re-executes modules without clearing the registry + if (!isClientOnlyMode() && tables.has(registryKey)) { throw new Error( `OlapTable with name ${name} and version ${config?.version ?? "unversioned"} already exists`, ); diff --git a/packages/ts-moose-lib/src/dmv2/sdk/sqlResource.ts b/packages/ts-moose-lib/src/dmv2/sdk/sqlResource.ts index 4362a0d725..28056fde55 100644 --- a/packages/ts-moose-lib/src/dmv2/sdk/sqlResource.ts +++ b/packages/ts-moose-lib/src/dmv2/sdk/sqlResource.ts @@ -1,4 +1,4 @@ -import { getMooseInternal } from "../internal"; +import { getMooseInternal, isClientOnlyMode } from "../internal"; import { OlapTable } from "./olapTable"; import { Sql, toStaticQuery } from "../../sqlHelpers"; @@ -43,7 +43,9 @@ export class SqlResource { }, ) { const sqlResources = getMooseInternal().sqlResources; - if (sqlResources.has(name)) { + // In client-only mode (MOOSE_CLIENT_ONLY=true), allow duplicate registrations + // to support Next.js HMR which re-executes modules without clearing the registry + if (!isClientOnlyMode() && sqlResources.has(name)) { throw new Error(`SqlResource with name ${name} already exists`); } sqlResources.set(name, this); diff --git a/packages/ts-moose-lib/tests/client-only-mode.test.ts b/packages/ts-moose-lib/tests/client-only-mode.test.ts new file mode 100644 index 0000000000..93a71707f7 --- /dev/null +++ b/packages/ts-moose-lib/tests/client-only-mode.test.ts @@ -0,0 +1,308 @@ +/** + * Test suite for MOOSE_CLIENT_ONLY mode + * + * When MOOSE_CLIENT_ONLY=true, resource registration should be permissive: + * - Duplicate registrations silently overwrite instead of throwing + * - This enables Next.js HMR to re-execute modules without errors + * - Applies to OlapTable, SqlResource (View, MaterializedView), and other resources + */ + +import { expect } from "chai"; +import { + OlapTable, + getTables, + SqlResource, + getSqlResources, +} from "../src/dmv2/index"; +import { getMooseInternal, isClientOnlyMode } from "../src/dmv2/internal"; + +describe("Client-Only Mode", () => { + let originalEnvValue: string | undefined; + + beforeEach(() => { + // Clear the registry before each test + const registry = getMooseInternal(); + registry.tables.clear(); + registry.sqlResources.clear(); + }); + + describe("isClientOnlyMode function", () => { + beforeEach(() => { + originalEnvValue = process.env.MOOSE_CLIENT_ONLY; + }); + + afterEach(() => { + // Restore original value + if (originalEnvValue !== undefined) { + process.env.MOOSE_CLIENT_ONLY = originalEnvValue; + } else { + delete process.env.MOOSE_CLIENT_ONLY; + } + }); + + it("should return false when MOOSE_CLIENT_ONLY is not set", () => { + delete process.env.MOOSE_CLIENT_ONLY; + expect(isClientOnlyMode()).to.equal(false); + }); + + it("should return false when MOOSE_CLIENT_ONLY is set to 'false'", () => { + process.env.MOOSE_CLIENT_ONLY = "false"; + expect(isClientOnlyMode()).to.equal(false); + }); + + it("should return true when MOOSE_CLIENT_ONLY is set to 'true'", () => { + process.env.MOOSE_CLIENT_ONLY = "true"; + expect(isClientOnlyMode()).to.equal(true); + }); + + it("should return false for other values", () => { + process.env.MOOSE_CLIENT_ONLY = "1"; + expect(isClientOnlyMode()).to.equal(false); + + process.env.MOOSE_CLIENT_ONLY = "yes"; + expect(isClientOnlyMode()).to.equal(false); + }); + }); + + describe("OlapTable registration", () => { + beforeEach(() => { + originalEnvValue = process.env.MOOSE_CLIENT_ONLY; + }); + + afterEach(() => { + if (originalEnvValue !== undefined) { + process.env.MOOSE_CLIENT_ONLY = originalEnvValue; + } else { + delete process.env.MOOSE_CLIENT_ONLY; + } + }); + + describe("when MOOSE_CLIENT_ONLY is not set (default behavior)", () => { + beforeEach(() => { + delete process.env.MOOSE_CLIENT_ONLY; + }); + + it("should throw error on duplicate table registration", () => { + interface TestData { + id: string; + value: number; + } + + // First registration should succeed + new OlapTable("DuplicateTable", { + orderByFields: ["id"], + }); + + // Second registration should throw + expect(() => { + new OlapTable("DuplicateTable", { + orderByFields: ["id"], + }); + }).to.throw( + "OlapTable with name DuplicateTable and version unversioned already exists", + ); + }); + + it("should throw error on duplicate versioned table registration", () => { + interface TestData { + id: string; + value: number; + } + + // First registration should succeed + new OlapTable("VersionedTable", { + orderByFields: ["id"], + version: "1.0", + }); + + // Second registration with same version should throw + expect(() => { + new OlapTable("VersionedTable", { + orderByFields: ["id"], + version: "1.0", + }); + }).to.throw( + "OlapTable with name VersionedTable and version 1.0 already exists", + ); + }); + + it("should allow different versions of the same table", () => { + interface TestData { + id: string; + } + + new OlapTable("MultiVersionTable", { + orderByFields: ["id"], + version: "1.0", + }); + + // Different version should succeed + new OlapTable("MultiVersionTable", { + orderByFields: ["id"], + version: "2.0", + }); + + const tables = getTables(); + expect(tables.size).to.equal(2); + expect(tables.has("MultiVersionTable_1.0")).to.be.true; + expect(tables.has("MultiVersionTable_2.0")).to.be.true; + }); + }); + + describe("when MOOSE_CLIENT_ONLY=true (permissive mode)", () => { + beforeEach(() => { + process.env.MOOSE_CLIENT_ONLY = "true"; + }); + + it("should allow duplicate table registration without throwing", () => { + interface TestData { + id: string; + value: number; + } + + // First registration + const table1 = new OlapTable("ClientOnlyDupeTable", { + orderByFields: ["id"], + }); + + // Second registration should NOT throw in client-only mode + const table2 = new OlapTable("ClientOnlyDupeTable", { + orderByFields: ["id"], + }); + + // Registry should have the second table (overwrite) + const tables = getTables(); + expect(tables.size).to.equal(1); + expect(tables.get("ClientOnlyDupeTable")).to.equal(table2); + expect(tables.get("ClientOnlyDupeTable")).to.not.equal(table1); + }); + + it("should allow duplicate versioned table registration", () => { + interface TestData { + id: string; + } + + const table1 = new OlapTable("VersionedDupeTable", { + orderByFields: ["id"], + version: "1.0", + }); + + const table2 = new OlapTable("VersionedDupeTable", { + orderByFields: ["id"], + version: "1.0", + }); + + const tables = getTables(); + expect(tables.size).to.equal(1); + expect(tables.get("VersionedDupeTable_1.0")).to.equal(table2); + expect(tables.get("VersionedDupeTable_1.0")).to.not.equal(table1); + }); + + it("should still support getTables introspection", () => { + interface TestData { + id: string; + } + + new OlapTable("IntrospectionTable1", { + orderByFields: ["id"], + }); + new OlapTable("IntrospectionTable2", { + orderByFields: ["id"], + }); + + const tables = getTables(); + expect(tables.size).to.equal(2); + expect(tables.has("IntrospectionTable1")).to.be.true; + expect(tables.has("IntrospectionTable2")).to.be.true; + }); + }); + }); + + describe("SqlResource registration", () => { + beforeEach(() => { + originalEnvValue = process.env.MOOSE_CLIENT_ONLY; + }); + + afterEach(() => { + if (originalEnvValue !== undefined) { + process.env.MOOSE_CLIENT_ONLY = originalEnvValue; + } else { + delete process.env.MOOSE_CLIENT_ONLY; + } + }); + + describe("when MOOSE_CLIENT_ONLY is not set (default behavior)", () => { + beforeEach(() => { + delete process.env.MOOSE_CLIENT_ONLY; + }); + + it("should throw error on duplicate SqlResource registration", () => { + // First registration should succeed + new SqlResource( + "DuplicateSqlResource", + ["CREATE VIEW test AS SELECT 1"], + ["DROP VIEW test"], + ); + + // Second registration should throw + expect(() => { + new SqlResource( + "DuplicateSqlResource", + ["CREATE VIEW test AS SELECT 1"], + ["DROP VIEW test"], + ); + }).to.throw( + "SqlResource with name DuplicateSqlResource already exists", + ); + }); + }); + + describe("when MOOSE_CLIENT_ONLY=true (permissive mode)", () => { + beforeEach(() => { + process.env.MOOSE_CLIENT_ONLY = "true"; + }); + + it("should allow duplicate SqlResource registration without throwing", () => { + // First registration + const resource1 = new SqlResource( + "ClientOnlyDupeSqlResource", + ["CREATE VIEW test AS SELECT 1"], + ["DROP VIEW test"], + ); + + // Second registration should NOT throw in client-only mode + const resource2 = new SqlResource( + "ClientOnlyDupeSqlResource", + ["CREATE VIEW test2 AS SELECT 2"], + ["DROP VIEW test2"], + ); + + // Registry should have the second resource (overwrite) + const resources = getSqlResources(); + expect(resources.size).to.equal(1); + expect(resources.get("ClientOnlyDupeSqlResource")).to.equal(resource2); + expect(resources.get("ClientOnlyDupeSqlResource")).to.not.equal( + resource1, + ); + }); + + it("should still support getSqlResources introspection", () => { + new SqlResource( + "IntrospectionResource1", + ["CREATE VIEW r1 AS SELECT 1"], + ["DROP VIEW r1"], + ); + new SqlResource( + "IntrospectionResource2", + ["CREATE VIEW r2 AS SELECT 2"], + ["DROP VIEW r2"], + ); + + const resources = getSqlResources(); + expect(resources.size).to.equal(2); + expect(resources.has("IntrospectionResource1")).to.be.true; + expect(resources.has("IntrospectionResource2")).to.be.true; + }); + }); + }); +});