From 2d29b0db4a96a797132204e5cd8f5d39861c70f4 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Tue, 2 Jun 2026 03:29:57 +0800 Subject: [PATCH] fix(all): avoid double-registration when serving the composed env MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Browser testing surfaced a real bug: the compile-input store was named `.objectstack/installed-packages/` — the runtime's reserved live-install folder. `MarketplaceInstallLocalPlugin` auto-rehydrates that folder at boot, so every app got registered twice when serving `--artifact` (once by the composed env, once by rehydrate), logged as `id=undefined` / `Overwriting package: undefined`, which destabilized the dev server. Move the compile-input store to `.objectstack/marketplace-packages/` so the composed artifact is the single source of truth at serve time. Genuine marketplace installs in `installed-packages/` are still folded in if present. Verified: clean boot registers the env exactly once, no id=undefined; all 9 apps load and serve seed data from one runtime; helpdesk/todo dashboards render. - compile script: store → marketplace-packages; also ingest live installed-packages - add `pnpm clean` - README: document the rationale + business-user testing findings (partial zh-CN coverage, empty personal dashboards) and a Verified section Co-Authored-By: Claude Opus 4.8 --- packages/all/README.md | 55 +++++++++++++++----- packages/all/package.json | 3 +- packages/all/scripts/compile-marketplace.mjs | 37 ++++++++++--- 3 files changed, 74 insertions(+), 21 deletions(-) diff --git a/packages/all/README.md b/packages/all/README.md index 5d1c89e..4b87d11 100644 --- a/packages/all/README.md +++ b/packages/all/README.md @@ -25,17 +25,16 @@ Seeded admin: `admin@objectos.ai` / `admin123`. ## How it works This piggy-backs on the runtime's **real** local-install mechanism. When you -install an App from the marketplace, `MarketplaceInstallLocalPlugin` writes it to -`.objectstack/installed-packages/.json` (a wrapper -`{ packageId, manifestId, version, manifest, … }` whose `manifest` is the App's -full compiled artifact) and rehydrates it on boot. The compile command reads -that same folder and composes everything into one static environment artifact. +install an App from the marketplace, `MarketplaceInstallLocalPlugin` persists it +as a wrapper entry `{ packageId, manifestId, version, manifest, … }` (where +`manifest` is the App's full compiled artifact). The compile command uses that +same wrapper format and composes everything into one static environment artifact. ``` packages/*/dist/objectstack.json ← each template, compiled & self-contained │ (1) install → wrapper entry, runtime's exact format ▼ -.objectstack/installed-packages/.json ← the REAL local-install folder +.objectstack/marketplace-packages/.json ← compile-input store (see note below) │ (2) compile (scripts/compile-marketplace.mjs) ▼ dist/objectstack.json ← ONE environment artifact: apps[9] @@ -44,11 +43,10 @@ dist/objectstack.json ← ONE environment artifact: apps objectstack dev all --artifact dist/objectstack.json ``` -1. **install** — populate `.objectstack/installed-packages/` from the workspace - templates, in the runtime's exact wrapper format, so the folder is - indistinguishable from a real marketplace install. Already-present entries are - left untouched — if you installed an App via the marketplace UI (or dropped a - compiled artifact into the folder), it's picked up as-is. +1. **install** — populate `.objectstack/marketplace-packages/` from the workspace + templates, in the runtime's exact wrapper format. Already-present entries are + left untouched. Genuine marketplace installs in `.objectstack/installed-packages/` + are also folded in if present. 2. **compile** — unwrap each installed entry to its artifact, concatenate all metadata (objects, apps, views, flows, hooks, data…), de-duplicate environment-level singletons (`roles` / `permissions` by name, `requires` as a @@ -56,6 +54,15 @@ objectstack dev all --artifact dist/objectstack.json 3. **serve** — `objectstack dev all --artifact …` loads the JSON directly and boots a single runtime hosting every app. +> **Why `marketplace-packages/`, not the runtime's `installed-packages/`?** +> The runtime auto-**rehydrates** `.objectstack/installed-packages/` at boot. If +> we used that folder as our compile input, every app would be registered +> **twice** when serving `--artifact` — once by the composed env and once by the +> rehydrate pass (observed as `id=undefined` / `Overwriting package: undefined` +> in the boot log, which destabilized the server). Keeping the compile input in a +> separate `marketplace-packages/` folder makes the composed artifact the single +> source of truth at serve time. (Found and fixed during browser testing.) + Scripts: | Command | Does | @@ -63,6 +70,7 @@ Scripts: | `pnpm compile` | (re)build `dist/objectstack.json` from the installed store | | `pnpm dev` | compile, then boot on `:4000` with a fresh ephemeral DB + seeded admin | | `pnpm start` | boot the already-compiled artifact (no recompile, persistent DB) | +| `pnpm clean` | remove the compile store + `dist/` (start over) | ## Why this does not break ADR-0019 ("one app per package") @@ -84,12 +92,35 @@ do not apply. This is the same reason `composeStacks()` exists in `@objectstack/spec`: composition is an environment-assembly primitive, not a package-authoring one. -## Caveats +## Verified + +Booted via `objectstack dev all --artifact dist/objectstack.json` and exercised +through the browser as a business user: + +- **Setup / first-run** — login + seeded admin + console launcher render; all 11 + tiles (9 apps + System Settings + Studio) present. +- **All 9 apps load and serve seed data** from the single composed runtime + (records per app: compliance 6, content 14, contracts 6, expense 5, helpdesk 9, + hr 7, procurement 4+5, pm 3, todo 16). +- **Dashboards aggregate correctly** — e.g. helpdesk Agent Workbench shows + SLA-breaching 6 / angry-customers 2 / awaiting-triage 1 from seed data. + +## Caveats & known findings - **Generic role/permission names collide.** `todo` and `content` both ship a `lead` / `contributor` role and permission; the compiler keeps the first and shadows the rest (logged on compile). Fine for a "see everything" dev environment; for production isolation each app would namespace its roles. +- **Partial zh-CN coverage (per-template, not aggregator).** Under a Chinese + locale, object/field labels are translated, but several dashboard titles, KPI + widget labels, and view/page nav items remain English (e.g. helpdesk's "My + Workbench", "SLA Breaching"; the `pm` app name). Each template needs to extend + its translation bundle to cover dashboards/views — out of scope for the + aggregator. +- **Empty *personal* dashboards for a fresh admin.** "My Work"-style dashboards + filter to the current user; seed records are owned by seed users, so the admin + sees 0 there until records are assigned. Org-wide dashboard widgets still + populate. - **Throwaway, not a product.** This package is for local exploration, demos, and cross-template QA — not something you publish to the marketplace. - Rebuild a template (`pnpm --filter @objectlab/ build`) then diff --git a/packages/all/package.json b/packages/all/package.json index ec5db48..5ad8c3e 100644 --- a/packages/all/package.json +++ b/packages/all/package.json @@ -8,7 +8,8 @@ "scripts": { "compile": "node scripts/compile-marketplace.mjs", "dev": "pnpm run compile && objectstack dev all --artifact dist/objectstack.json -p 4000 --fresh", - "start": "objectstack dev all --artifact dist/objectstack.json -p 4000" + "start": "objectstack dev all --artifact dist/objectstack.json -p 4000", + "clean": "rm -rf .objectstack/marketplace-packages dist" }, "dependencies": { "@objectstack/account": "^7.4.1", diff --git a/packages/all/scripts/compile-marketplace.mjs b/packages/all/scripts/compile-marketplace.mjs index 9e7c3d8..2b7da54 100644 --- a/packages/all/scripts/compile-marketplace.mjs +++ b/packages/all/scripts/compile-marketplace.mjs @@ -50,9 +50,17 @@ const HERE = dirname(fileURLToPath(import.meta.url)); const ALL_DIR = resolve(HERE, '..'); // packages/all (the runtime cwd for `dev all`) const PACKAGES_DIR = resolve(ALL_DIR, '..'); // packages -// The real on-disk location the runtime's MarketplaceInstallLocalPlugin uses: -// storageDir = resolve(process.cwd(), '.objectstack/installed-packages') -const INSTALLED_DIR = join(ALL_DIR, '.objectstack', 'installed-packages'); +// Compile-input store. We deliberately do NOT use the runtime's live-install +// folder (`.objectstack/installed-packages/`): the MarketplaceInstallLocalPlugin +// auto-rehydrates THAT folder at boot, which would double-register every app on +// top of the `--artifact` env we serve (id=undefined / "Overwriting package"). +// This is a compile-time cache, distinct from the runtime's live store, so the +// composed artifact is the single source of truth at serve time. +const INSTALLED_DIR = join(ALL_DIR, '.objectstack', 'marketplace-packages'); +// If a genuine marketplace install populated the runtime's live folder, fold it +// in too — but warn, because serving `--artifact` from this cwd would also +// rehydrate it (run `start` from a clean cwd, or uninstall, to avoid that). +const LIVE_INSTALL_DIR = join(ALL_DIR, '.objectstack', 'installed-packages'); const OUT = join(ALL_DIR, 'dist', 'objectstack.json'); // Mirror MarketplaceInstallLocalPlugin.safeFilename so files we write are @@ -114,14 +122,14 @@ function installFromWorkspace() { return installed; } -/** Read every installed entry; unwrap to the App's full artifact. */ -function readInstalledArtifacts() { - if (!existsSync(INSTALLED_DIR)) return []; +/** Read every installed entry from a store; unwrap to the App's full artifact. */ +function readStore(dir) { + if (!existsSync(dir)) return []; const out = []; - for (const file of readdirSync(INSTALLED_DIR).filter((f) => f.endsWith('.json')).sort()) { + for (const file of readdirSync(dir).filter((f) => f.endsWith('.json')).sort()) { let entry; try { - entry = JSON.parse(readFileSync(join(INSTALLED_DIR, file), 'utf8')); + entry = JSON.parse(readFileSync(join(dir, file), 'utf8')); } catch { log(` ! ${file}: invalid JSON, skipped`); continue; @@ -135,6 +143,19 @@ function readInstalledArtifacts() { return out; } +/** Read installed Apps: the compile cache, plus any genuine live installs. */ +function readInstalledArtifacts() { + const cache = readStore(INSTALLED_DIR); + const live = readStore(LIVE_INSTALL_DIR); + if (live.length > 0) { + log(` ⓘ folding in ${live.length} live marketplace install(s) from .objectstack/installed-packages/`); + log(' (the runtime rehydrates that folder too — run `start` from a clean cwd to avoid double-load)'); + } + // De-dupe by namespace; cache wins (it's the deterministic workspace copy). + const seen = new Set(cache.map((e) => e.source)); + return [...cache, ...live.filter((e) => !seen.has(e.source))]; +} + /** `compile` — compose installed Apps into one environment artifact. */ function compile(store) { const env = {