From 3637be10dfe6c8737a3cca4ec1b45f10e017af83 Mon Sep 17 00:00:00 2001 From: NickGhignatti Date: Sun, 29 Mar 2026 18:51:20 +0200 Subject: [PATCH 1/2] chore: clean package.json --- documentation/developer/page-12.qd | 8 +++--- package.json | 42 ++++++++++++++++++------------ 2 files changed, 30 insertions(+), 20 deletions(-) diff --git a/documentation/developer/page-12.qd b/documentation/developer/page-12.qd index 93a5eca..b7723f7 100644 --- a/documentation/developer/page-12.qd +++ b/documentation/developer/page-12.qd @@ -12,7 +12,7 @@ Because the entire ecosystem is containerized, your day-to-day workflow relies e For active development, you will almost exclusively use the `dev` script. .code lang:{bash} - npm run dev + npm run docker:dev What happens when you run this? 1. The script first executes setup.js to verify your `.env` file and cryptographic keys are intact. @@ -54,7 +54,7 @@ You do not need to restart the server when you change code. If you just want to run the application to test it without making code changes (or if you are deploying it to a staging server), use the standard start command: .code lang:{bash} - npm start + npm run docker:start This command runs the stack using only the base `docker-compose.yml` file. - It runs in detached mode (`-d`), meaning it will start the containers in the background and free up your terminal. @@ -69,7 +69,7 @@ When you are done working, you should gracefully spin down the containers and re To stop the application: .code lang:{bash} - npm run stop + npm run docker:stop *The "Nuclear" Option* Occasionally, during development, you might corrupt your database state (e.g., by uploading an invalid building schema while testing). If you want to wipe everything and start completely fresh, you can use the clear command. @@ -78,7 +78,7 @@ Occasionally, during development, you might corrupt your database state (e.g., b This command is destructive. It will permanently delete the persistent Docker volumes containing your databases. .code lang:{bash} - npm run clear + npm run db:clear After running this, the next time you boot the app, the databases will be completely empty. You will need to `run npm run enterprise:create` again to bootstrap a new admin account! diff --git a/package.json b/package.json index f3d49df..5a1c797 100644 --- a/package.json +++ b/package.json @@ -6,28 +6,38 @@ "server/*" ], "scripts": { - "setup": "node setup.js", - "dev": "npm run setup && docker compose -f docker-compose.yml -f docker-compose.dev.yml up --watch --build", - "start": "npm run setup && docker compose up --build -d", - "stop": "docker compose down --remove-orphans", - "logs": "docker compose logs -f", - "release": "semantic-release", - "clear": "docker compose exec auth-db mongosh authdb --eval \"db.dropDatabase()\" && docker compose exec twin-db mongosh twindb --eval \"db.dropDatabase()\" && docker compose exec notification-db mongosh notificationdb --eval \"db.dropDatabase()\"", - "build:api": "npm run copy:yamls", - "copy:yamls": "shx cp server/auth-service/openapi.yaml landing-page/api/auth.yaml && shx cp server/twin-service/openapi.yaml landing-page/api/twin.yaml && shx cp server/notification-service/openapi.yaml landing-page/api/notification.yaml", - "watch:api": "nodemon --watch server --ext yaml --exec \"npm run copy:yamls\"", - "docs:dev": "npm run build:api && concurrently \"npm:docs:landing\" \"npm:docs:user\" \"npm:docs:devs\" \"npm:watch:api\"", + "// --- ENVIRONMENT & DOCKER ---": "", + "env:setup": "node setup.js", + "docker:dev": "npm run env:setup && docker compose -f docker-compose.yml -f docker-compose.dev.yml up --watch --build", + "docker:start": "npm run env:setup && docker compose up --build -d", + "docker:down": "docker compose down --remove-orphans", + "docker:logs": "docker compose logs -f", + + "// --- DATABASE ---": "", + "db:clear:auth": "docker compose exec auth-db mongosh authdb --eval \"db.dropDatabase()\"", + "db:clear:twin": "docker compose exec twin-db mongosh twindb --eval \"db.dropDatabase()\"", + "db:clear:notification": "docker compose exec notification-db mongosh notificationdb --eval \"db.dropDatabase()\"", + "db:clear": "npm run db:clear:auth && npm run db:clear:twin && npm run db:clear:notification", + + "// --- DOCUMENTATION ---": "", "docs:landing": "http-server landing-page -p 8000 -c-1 --cors", - "docs:user": "quarkdown c documentation/user/main.qd --out landing-page/user -p -w", - "docs:devs": "quarkdown c documentation/developer/main.qd --out landing-page/dev -p -w", - "docs:build": "npm run build:api && quarkdown c documentation/user/main.qd --out landing-page/user && quarkdown c documentation/developer/main.qd --out landing-page/dev", + "docs:build:user": "quarkdown c documentation/user/main.qd --out landing-page/user", + "docs:build:dev": "quarkdown c documentation/developer/main.qd --out landing-page/dev", + "docs": "npm run docs:build:user && npm run docs:build:dev && npm run docs:landing", + + "// --- TESTING ---": "", "test:auth": "cd server/auth-service && npm test", "test:twin": "cd server/twin-service && npm test", "test:notification": "cd server/notification-service && npm test", "test:socket": "cd server/socket-service && npm test", "test:client": "cd client && npm run test:unit", - "test": "concurrently \"npm run test:auth\" \"npm run test:twin\" \"npm run test:notification\" \"npm run test:client\"", - "enterprise:create": "node --env-file=.env admin-creator.js" + "test": "concurrently \"npm run test:auth\" \"npm run test:twin\" \"npm run test:notification\" \"npm run test:client\" \"npm run test:socket\"", + + "// --- UTILITIES ---": "", + "enterprise:create": "node --env-file=.env admin-creator.js", + + "// --- RELEASE ---": "", + "release": "semantic-release" }, "devDependencies": { "@semantic-release/changelog": "^6.0.0", From febbd324dc1acf8efef42620af056e5bd58ec79c Mon Sep 17 00:00:00 2001 From: NickGhignatti Date: Sun, 29 Mar 2026 18:51:44 +0200 Subject: [PATCH 2/2] fix: stores promises --- client/src/stores/__tests__/buildings.spec.ts | 13 ++- client/src/stores/__tests__/domain.spec.ts | 50 +++++---- client/src/stores/authentication.ts | 33 +++--- client/src/stores/buildings.ts | 44 ++++---- client/src/stores/domain.ts | 101 ++++++++++++------ server/twin-service/__tests__/setup.ts | 2 - 6 files changed, 154 insertions(+), 89 deletions(-) diff --git a/client/src/stores/__tests__/buildings.spec.ts b/client/src/stores/__tests__/buildings.spec.ts index 4ec2dbd..92cc214 100644 --- a/client/src/stores/__tests__/buildings.spec.ts +++ b/client/src/stores/__tests__/buildings.spec.ts @@ -108,13 +108,18 @@ describe('useBuildingsStore', () => { }) describe('fetch', () => { - it('does not call authenticatedFetch when already loading', async () => { + it('only calls the API once when multiple fetches happen concurrently', async () => { + vi.mocked(makeRequest).mockResolvedValue( + makeResponse(true, [makeBuilding('b1')]) as unknown as Response, + ) const store = useBuildingsStore() - store.loading = true + const memberships = [makeMembership('acme')] - await store.fetch([makeMembership('acme')]) + const fetch1 = store.fetch(memberships) + const fetch2 = store.fetch(memberships) + await Promise.all([fetch1, fetch2]) - expect(makeRequest).not.toHaveBeenCalled() + expect(makeRequest).toHaveBeenCalledTimes(1) }) it('does not call authenticatedFetch when all memberships are already cached', async () => { diff --git a/client/src/stores/__tests__/domain.spec.ts b/client/src/stores/__tests__/domain.spec.ts index a0c4694..f795520 100644 --- a/client/src/stores/__tests__/domain.spec.ts +++ b/client/src/stores/__tests__/domain.spec.ts @@ -53,7 +53,7 @@ describe('useDomainsStore', () => { }) describe('fetchMemberships', () => { - it('calls authenticatedFetch with the account-scoped URL', async () => { + it('calls makeRequest with the account-scoped URL', async () => { vi.mocked(makeRequest).mockResolvedValue( makeResponse(true, { domains: [] }) as unknown as Response, ) @@ -95,7 +95,7 @@ describe('useDomainsStore', () => { expect(store.memberships).toEqual([]) }) - it('does not call authenticatedFetch when memberships are already cached', async () => { + it('does not call makeRequest when memberships are already cached', async () => { const store = useDomainsStore() store.memberships = [makeMembership('acme')] @@ -104,13 +104,17 @@ describe('useDomainsStore', () => { expect(makeRequest).not.toHaveBeenCalled() }) - it('does not call authenticatedFetch when a fetch is already in flight', async () => { + it('only calls makeRequest once when multiple fetches happen concurrently', async () => { + vi.mocked(makeRequest).mockResolvedValue( + makeResponse(true, { domains: [] }) as unknown as Response, + ) const store = useDomainsStore() - store.loading = true - await store.fetchMemberships() + const fetch1 = store.fetchMemberships() + const fetch2 = store.fetchMemberships() + await Promise.all([fetch1, fetch2]) - expect(makeRequest).not.toHaveBeenCalled() + expect(makeRequest).toHaveBeenCalledTimes(1) }) it('does not overwrite an existing empty-array cache', async () => { @@ -159,7 +163,7 @@ describe('useDomainsStore', () => { }) describe('fetchAll', () => { - it('calls authenticatedFetch with the shared domains endpoint', async () => { + it('calls makeRequest with the shared domains endpoint', async () => { vi.mocked(makeRequest).mockResolvedValue( makeResponse(true, { domains: [] }) as unknown as Response, ) @@ -190,7 +194,7 @@ describe('useDomainsStore', () => { expect(store.allDomains).toEqual([]) }) - it('does not call authenticatedFetch when allDomains is already cached', async () => { + it('does not call makeRequest when allDomains is already cached', async () => { const store = useDomainsStore() store.allDomains = [makeDomain('acme')] @@ -199,13 +203,17 @@ describe('useDomainsStore', () => { expect(makeRequest).not.toHaveBeenCalled() }) - it('does not call authenticatedFetch when a fetch is already in flight', async () => { + it('only calls makeRequest once when multiple fetchAll happen concurrently', async () => { + vi.mocked(makeRequest).mockResolvedValue( + makeResponse(true, { domains: [] }) as unknown as Response, + ) const store = useDomainsStore() - store.loadingAll = true - await store.fetchAll() + const fetch1 = store.fetchAll() + const fetch2 = store.fetchAll() + await Promise.all([fetch1, fetch2]) - expect(makeRequest).not.toHaveBeenCalled() + expect(makeRequest).toHaveBeenCalledTimes(1) }) it('does not overwrite an existing empty-array cache', async () => { @@ -342,16 +350,20 @@ describe('useSubdomainsStore', () => { }) describe('fetch', () => { - it('does not call authenticatedFetch when already loading', async () => { + it('does not call makeRequest when already loading', async () => { + vi.mocked(makeRequest).mockResolvedValue(makeResponse(true, ['sub1']) as unknown as Response) + const store = useSubdomainsStore() - store.loading = true + const memberships = [makeMembership('acme')] - await store.fetch([makeMembership('acme')]) + const fetch1 = store.fetch(memberships) + const fetch2 = store.fetch(memberships) + await Promise.all([fetch1, fetch2]) - expect(makeRequest).not.toHaveBeenCalled() + expect(makeRequest).toHaveBeenCalledTimes(1) }) - it('does not call authenticatedFetch when all memberships are already cached', async () => { + it('does not call makeRequest when all memberships are already cached', async () => { const store = useSubdomainsStore() store.byDomain = { acme: ['sub1'] } @@ -360,13 +372,13 @@ describe('useSubdomainsStore', () => { expect(makeRequest).not.toHaveBeenCalled() }) - it('does not call authenticatedFetch when the memberships list is empty', async () => { + it('does not call makeRequest when the memberships list is empty', async () => { await useSubdomainsStore().fetch([]) expect(makeRequest).not.toHaveBeenCalled() }) - it('calls authenticatedFetch with the correct URL for each missing domain', async () => { + it('calls makeRequest with the correct URL for each missing domain', async () => { vi.mocked(makeRequest).mockResolvedValue( makeResponse(true, ['sub1']) as unknown as Response, ) diff --git a/client/src/stores/authentication.ts b/client/src/stores/authentication.ts index a2317c8..4c86f68 100644 --- a/client/src/stores/authentication.ts +++ b/client/src/stores/authentication.ts @@ -8,6 +8,7 @@ export const useAuthStore = defineStore('authentication', { accountName: null as string | null, isAuthenticated: false, isHydrated: false, // has the /me call completed? + _hydratePromise: null as Promise | null, }), actions: { @@ -18,7 +19,7 @@ export const useAuthStore = defineStore('authentication', { const data = await res.json() if (!res.ok) { console.log(`Failed to login: ${data.type} - ${data.message}`) - return; + return } this.accountName = data.account.accountName this.isAuthenticated = true @@ -52,18 +53,26 @@ export const useAuthStore = defineStore('authentication', { // Called once on app startup to re-hydrate from the cookie async hydrate() { - try { - const res = await makeRequest('/auth/me') - if (res.ok) { - const data = await res.json() - this.accountName = data.accountName - this.isAuthenticated = true + if (this.isHydrated) return Promise.resolve() + if (this._hydratePromise) return this._hydratePromise + + this._hydratePromise = (async () => { + try { + const res = await makeRequest('/auth/me') + if (res.ok) { + const data = await res.json() + this.accountName = data.accountName + this.isAuthenticated = true + } + } catch { + // Cookie missing or expired — user is logged out, do nothing + } finally { + this.isHydrated = true + this._hydratePromise = null } - } catch { - // Cookie missing or expired — user is logged out, do nothing - } finally { - this.isHydrated = true - } + })() + + return this._hydratePromise }, }, }) diff --git a/client/src/stores/buildings.ts b/client/src/stores/buildings.ts index ede032a..10ad7dc 100644 --- a/client/src/stores/buildings.ts +++ b/client/src/stores/buildings.ts @@ -7,6 +7,7 @@ export const useBuildingsStore = defineStore('buildings', { state: () => ({ byDomain: {} as Record, loading: false, + _fetchPromise: null as Promise | null, }), getters: { @@ -24,32 +25,39 @@ export const useBuildingsStore = defineStore('buildings', { }, actions: { - async fetch(memberships: DomainMembership[]) { - if (this.loading) return + async fetch(memberships: DomainMembership[]): Promise { + if (this._fetchPromise) { + return this._fetchPromise.then(() => this.fetch(memberships)) + } // Only fetch domains not yet in cache const missing = memberships.filter((m) => !(m.domainName in this.byDomain)) - if (missing.length === 0) return + if (missing.length === 0) return Promise.resolve() this.loading = true - try { - // All domains fetched in parallel — the key fix vs the old sequential loop - await Promise.all( - missing.map(async (m) => { - try { - const res = await makeRequest(`/twin/buildings/${m.domainName}`) - this.byDomain[m.domainName] = res.ok ? await res.json() : [] - } catch { - this.byDomain[m.domainName] = [] - } - }), - ) - } finally { - this.loading = false - } + this._fetchPromise = (async () => { + try { + await Promise.all( + missing.map(async (m) => { + try { + const res = await makeRequest(`/twin/buildings/${m.domainName}`) + this.byDomain[m.domainName] = res.ok ? await res.json() : [] + } catch { + this.byDomain[m.domainName] = [] + } + }), + ) + } finally { + this.loading = false + this._fetchPromise = null + } + })() + + return this._fetchPromise }, invalidate() { this.byDomain = {} + this._fetchPromise = null }, }, }) diff --git a/client/src/stores/domain.ts b/client/src/stores/domain.ts index 35d190a..e77b2e3 100644 --- a/client/src/stores/domain.ts +++ b/client/src/stores/domain.ts @@ -9,37 +9,58 @@ export const useDomainsStore = defineStore('domains', { allDomains: null as Domain[] | null, loading: false, loadingAll: false, + _membershipsPromise: null as Promise | null, + _allDomainsPromise: null as Promise | null, }), actions: { async fetchMemberships() { - if (this.memberships !== null || this.loading) return // already cached or in-flight + if (this.memberships !== null) return Promise.resolve() + + // If a fetch is currently in progress, return that existing promise + // so the caller awaits the same request instead of resolving instantly. + if (this._membershipsPromise) return this._membershipsPromise + this.loading = true - try { - const { accountName } = useAuthStore() - const res = await makeRequest(`/auth/domains/${accountName}`) - const data = await res.json() - this.memberships = res.ok ? (data.domains ?? []) : [] - } finally { - this.loading = false - } + this._membershipsPromise = ( async () => { + try { + const { accountName } = useAuthStore() + const res = await makeRequest(`/auth/domains/${accountName}`) + const data = await res.json() + this.memberships = res.ok ? (data.domains ?? []) : [] + } finally { + this.loading = false + this._membershipsPromise = null + } + })() + + return this._membershipsPromise }, async fetchAll() { - if (this.allDomains !== null || this.loadingAll) return + if (this.allDomains !== null) return Promise.resolve() + if (this._allDomainsPromise) return this._allDomainsPromise + this.loadingAll = true - try { - const res = await makeRequest('/auth/domains') - const data = await res.json() - this.allDomains = res.ok ? (data.domains ?? []) : [] - } finally { - this.loadingAll = false - } + this._allDomainsPromise = ( async () => { + try { + const res = await makeRequest('/auth/domains') + const data = await res.json() + this.allDomains = res.ok ? (data.domains ?? []) : [] + } finally { + this.loadingAll = false + this._allDomainsPromise = null + } + })() + + return this._allDomainsPromise }, invalidate() { this.memberships = null this.allDomains = null + this._membershipsPromise = null + this._allDomainsPromise = null }, }, }) @@ -48,33 +69,45 @@ export const useSubdomainsStore = defineStore('subdomains', { state: () => ({ byDomain: {} as Record, loading: false, + _fetchPromise: null as Promise | null, }), actions: { - async fetch(memberships: DomainMembership[]) { - if (this.loading) return + async fetch(memberships: DomainMembership[]): Promise { + if (this._fetchPromise) { + await this._fetchPromise + return await this.fetch(memberships) + } + const missing = memberships.filter((m) => !(m.domainName in this.byDomain)) - if (missing.length === 0) return + if (missing.length === 0) return Promise.resolve() this.loading = true - try { - await Promise.allSettled( - missing.map(async (m) => { - try { - const res = await makeRequest(`/auth/subdomains/${m.domainName}`) - this.byDomain[m.domainName] = res.ok ? await res.json() : [] - } catch { - this.byDomain[m.domainName] = [] - } - }), - ) - } finally { - this.loading = false - } + + this._fetchPromise = (async () => { + try { + await Promise.allSettled( + missing.map(async (m) => { + try { + const res = await makeRequest(`/auth/subdomains/${m.domainName}`) + this.byDomain[m.domainName] = res.ok ? await res.json() : [] + } catch { + this.byDomain[m.domainName] = [] + } + }), + ) + } finally { + this.loading = false + this._fetchPromise = null + } + })() + + return this._fetchPromise }, invalidate() { this.byDomain = {} + this._fetchPromise = null }, }, }) diff --git a/server/twin-service/__tests__/setup.ts b/server/twin-service/__tests__/setup.ts index 40730ee..9458a8f 100644 --- a/server/twin-service/__tests__/setup.ts +++ b/server/twin-service/__tests__/setup.ts @@ -20,7 +20,5 @@ afterEach(async () => { }); afterAll(async () => { - // Disconnect this worker's mongoose connection. - // The MongoMemoryServer itself is stopped by globalTeardown.cjs. await mongoose.disconnect(); }); \ No newline at end of file