@murumets-ee/menus 0.110.0 → 0.111.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/dist/seed.mjs.map DELETED
@@ -1 +0,0 @@
1
- {"version":3,"file":"seed.mjs","names":[],"sources":["../src/seed.ts"],"sourcesContent":["/**\n * The `main` menu seeder — creates the one menu row a site's navigation needs\n * before anything can render it.\n *\n * Contributed as `menus:main` from the `menus()` plugin factory (which lives\n * in `@murumets-ee/menus-ui`, to avoid a circular build dep) and reached\n * through the `@murumets-ee/menus/seed` subpath. Its own build entry so the\n * lazy import from the plugin keeps `@murumets-ee/core/clients` — which\n * carries `import 'server-only'` — off the config-load path.\n *\n * ## Why the row matters more than what is in it\n *\n * A fresh install has ZERO `menu` rows, so `getMenu('main', …)` resolves\n * `null`, `/v1/_menu?handle=main` answers 404, and every consumer treats that\n * correctly as \"this site has no such menu\" — the admin canvas renders an\n * empty nav, the live site renders an empty nav, and nothing anywhere is\n * wrong. It just looks exactly like a bug. `PRIMARY_MENU_HANDLE` is `'main'`,\n * so even a template's hand-written fallback header reads that handle and\n * finds nothing. Creating the row is what makes the handle resolvable and the\n * editor reachable; the items are a demo convenience on top.\n *\n * ## Why the items' source entity is CONFIGURED, not assumed\n *\n * This seeder started life in `apps/admin`, where it could\n * `import { Page } from '@/entities'` and fill the menu with links to\n * published pages. A package cannot: `page` is an APP entity, and nothing in\n * `packages/*` resolves `'page'` against `app.entities` — the menus editor\n * itself derives its entity-type picker by ITERATING the registry and\n * filtering on `searchable()`, never by naming a type. Assuming `page` here\n * would make this the first package in the repo to break that.\n *\n * So the name arrives the way `@murumets-ee/content`'s chrome provider gets\n * its document entities: as plugin config from the app —\n * `menus({ seedItemsFrom: 'page' })` — resolved against `app.entities` at run\n * time and degrading to an EMPTY tree when unset or unregistered. That is the\n * same \"make the thing exist, leave it blank\" shape the chrome seeder uses,\n * and it means the seeder is never a button that does nothing: it always\n * creates the row that was missing.\n *\n * ## What it assumes about its caller\n *\n * An admin context — `runSeederAction` and `lumi seed` both establish one,\n * which is what makes `getApp()` and the entity clients resolve. Auth, the\n * admin-only role check and the production gate live in those wrappers.\n */\n\nimport { getApp } from '@murumets-ee/core'\nimport { createAdminClient } from '@murumets-ee/core/clients'\nimport { asc, eq } from 'drizzle-orm'\n// `AdminClient.getTable()` returns `PgTableWithColumns<any>` — Drizzle cannot\n// type a dynamically-generated column schema — so a column read off it is\n// `any` until it is named. `PgColumn` is what every WHERE/ORDER BY helper in\n// this repo actually wants, and naming it is the difference between a typed\n// column reference and one that silently counts against type coverage. Same\n// idiom as `@murumets-ee/queue`'s `trash-purge.ts`.\nimport type { PgColumn } from 'drizzle-orm/pg-core'\nimport { Menu } from './entity.js'\nimport type { MenuItem } from './menu-item-schema.js'\n\n/**\n * The exact shape this seeder writes — every property REQUIRED.\n *\n * `MenuItem` cannot be used directly as the value handed to\n * `AdminClient.create`. Its optional members are declared `link?: … |\n * undefined` for `exactOptionalPropertyTypes`, so its implicit index signature\n * is `MenuLinkValue | undefined`, and `undefined` is not a member of\n * `JsonValue` (`{ [key: string]: JsonValue }`). The same code compiled in\n * `apps/admin` only because that app does not extend the root tsconfig and so\n * never had `exactOptionalPropertyTypes` applied to it (seeding plan F011) —\n * moving the file into a package is what surfaced it.\n *\n * A cast would silence it. Narrowing the constructed shape instead means the\n * value is assignable on its own terms: this seeder emits entity links and\n * nothing else, so every field it writes is one it always writes. The\n * `satisfies MenuItem[]` below is what keeps that honest — if the tree schema\n * gains a required member, this fails to compile rather than writing a row the\n * validator will reject at run time.\n */\ntype SeededMenuItem = {\n id: string\n kind: 'link'\n link: { kind: 'entity'; entity: string; id: string }\n label: null\n children: SeededMenuItem[]\n}\n\n/**\n * Postgres SQLSTATE `23505` (`unique_violation`) — surfaces from postgres-js as\n * an Error with `code === '23505'`; Drizzle re-wraps it but preserves `code` on\n * the `cause` chain, so walk both. `WeakSet` guards a self-referential cause\n * cycle.\n *\n * Same shape as `@murumets-ee/admin-ui`'s nav-layout / locale / message route\n * handlers, which is the point of copying it rather than inventing one: this is\n * the repo's established way to tell \"somebody else won the race\" from \"the\n * write is wrong\", and a second spelling of it would be a second thing to keep\n * correct.\n */\nfunction isUniqueViolation(err: unknown): boolean {\n const seen = new WeakSet<object>()\n let cursor = err\n while (typeof cursor === 'object' && cursor !== null) {\n if (seen.has(cursor)) return false\n seen.add(cursor)\n const e = cursor as { code?: unknown; cause?: unknown }\n if (typeof e.code === 'string' && e.code === '23505') return true\n cursor = e.cause\n }\n return false\n}\n\n/** The handle every consumer in this repo asks for by default. */\nconst MAIN_HANDLE = 'main'\n\n/**\n * How many entity rows become menu items.\n *\n * A seeded demo menu, not a sitemap — and `findMany` with no limit on a site\n * that has been used for a while would put every page ever written into the\n * header. Six is a plausible top-level navigation.\n */\nconst MAX_SEEDED_ITEMS = 6\n\n/**\n * Build the seeded item tree from the app's configured source entity.\n *\n * Returns `[]` — a menu with no items, which is a valid and useful menu —\n * whenever the source cannot be used, rather than throwing or skipping the\n * menu itself. Three distinct reasons land in that branch and none of them is\n * an error:\n *\n * - the app passed no `seedItemsFrom` (the default: no opinion about which\n * entity is \"pages\");\n * - it named an entity this app never registered;\n * - the named entity has no `status` column, so it is not `publishable()` and\n * the publish filter below cannot be applied. Filling a nav with rows whose\n * public visibility we cannot establish is worse than filling it with none.\n */\nasync function buildSeededItems(entityName: string | undefined): Promise<SeededMenuItem[]> {\n if (!entityName) return []\n const entity = getApp().entities.get(entityName)\n if (!entity) return []\n\n const client = createAdminClient(entity)\n // Read as an index into an unknown-valued record, THEN narrowed to\n // `PgColumn` once presence is established. Going straight to `PgColumn`\n // would assert a shape for an entity chosen by app config at run time —\n // exactly the thing that cannot be known here.\n const table = client.getTable() as Record<string, unknown>\n const status = table.status\n const createdAt = table.createdAt\n const id = table.id\n if (!status || !createdAt || !id) return []\n const statusCol = status as PgColumn\n const createdAtCol = createdAt as PgColumn\n const idCol = id as PgColumn\n\n // `orderBy` and the published filter are both load-bearing rather than tidy:\n //\n // - without an order, WHICH six rows become the navigation is whatever the\n // planner returns, so two installs seeded from the same content get\n // different menus and neither is reproducible;\n // - without the filter a DRAFT row becomes an item whose canonical path\n // does not resolve for an anonymous reader, so `getMenu` drops it (fail\n // closed) and the seeded menu silently has fewer items than it created.\n //\n // `id` breaks the tie: `createdAt` alone is not a total order, and two rows\n // created in the same millisecond would leave the planner to decide — which\n // is the non-reproducibility the ordering exists to remove.\n const rows = await client.findMany({\n where: eq(statusCol, 'published'),\n orderBy: [asc(createdAtCol), asc(idCol)],\n limit: MAX_SEEDED_ITEMS,\n })\n\n return rows.map((row) => ({\n // A stable id per item is the DND identity and the audit-diff anchor. It\n // is minted here rather than left absent because the write schema requires\n // one and the editor mints one client-side for every item it creates.\n id: crypto.randomUUID(),\n kind: 'link',\n link: { kind: 'entity', entity: entityName, id: row.id as string },\n // `null` means \"use the LIVE entity title, per locale\", so a seeded menu is\n // correct in every locale the rows are translated into and stays correct\n // when someone renames one. Hardcoded labels would be an English demo menu\n // on an Estonian site, and stale the first time a title changed.\n label: null,\n children: [],\n })) satisfies MenuItem[]\n}\n\n/**\n * Create the `main` menu if this site has none.\n *\n * Idempotent on two levels rather than by a flag. It FINDS first — by the\n * HANDLE, not by \"is there any menu at all\", because a site may well have a\n * `footer` menu and no `main`, and a blanket existence check would leave the\n * one handle everything asks for missing. And it treats a unique-violation on\n * the insert as `{ created: 0 }`, which covers the window between the find and\n * the create when two runs overlap.\n *\n * @param options.seedItemsFrom entity name whose published rows become the\n * menu items. Omitted → an empty menu. See the module docblock.\n */\nexport async function seedMainMenu(options?: {\n seedItemsFrom?: string | undefined\n}): Promise<{ created: number }> {\n const menuClient = createAdminClient(Menu)\n\n // Via `Record<string, unknown>`, not a direct `.handle` read: `getTable()` is\n // `PgTableWithColumns<any>`, so the PROPERTY ACCESS is `any` before any cast\n // can apply to it. Indexing an unknown-valued record makes the read `unknown`\n // and the narrowing explicit — which is also what keeps this package at 100%\n // type coverage instead of quietly spending a fraction of a percent.\n const menuTable = menuClient.getTable() as Record<string, unknown>\n const handle = menuTable.handle\n // Presence-checked before the cast, like the three reads in\n // `buildSeededItems` — but THROWING where those return an empty tree, and\n // the difference is the point rather than an inconsistency. Those read an\n // entity the APP chose, where \"no `status` column\" means \"this project models\n // content differently\" and degrading is correct. `Menu` is this package's\n // own entity: a missing `handle` is not a project's choice, it is this\n // package being broken, and reporting `created: 0` would file that under\n // \"nothing to do\".\n if (!handle) {\n throw new Error(\n \"menus:main — the `menu` table has no `handle` column. This is @murumets-ee/menus' own \" +\n 'entity, so this indicates a broken build or a stale dist rather than a project ' +\n 'configuration problem.',\n )\n }\n const handleCol = handle as PgColumn\n const existing = await menuClient.findMany({\n where: eq(handleCol, MAIN_HANDLE),\n limit: 1,\n })\n if (existing.length > 0) return { created: 0 }\n\n const tree = await buildSeededItems(options?.seedItemsFrom)\n\n try {\n await menuClient.create({\n handle: MAIN_HANDLE,\n title: 'Main navigation',\n tree,\n // Published, deliberately: `getMenu` reads through a publish-filtered\n // client, so a draft menu is indistinguishable from an absent one to\n // every reader, and the only symptom is navigation that never appears\n // until someone finds the row.\n status: 'published',\n // Nothing stamps this on create — `publishable()`'s auto-stamp lives in\n // `beforeUpdate` — so a row created as published would carry a null\n // publish timestamp forever.\n publishedAt: new Date(),\n })\n } catch (err) {\n // The find-then-create window. Two runs — the Seed page and `lumi seed`, or\n // two operators — can both see no `main` row and both attempt the insert.\n // `uq_menu_handle` (a partial unique index, live rows only) is what keeps\n // that from producing two menus, so the data was never at risk; what the\n // loser got was a raw SQLSTATE from the driver, which names nothing an\n // operator can act on. `{ created: 0 }` is the honest answer and the same\n // one the find above returns: the row this seeder exists to guarantee is\n // there, and this call is not what put it there.\n //\n // ONLY 23505 — every other create failure propagates. A blanket catch here\n // would report success for a validation error or a dead connection, which\n // is how a seeder that silently does nothing gets shipped.\n //\n // No equivalent is needed in `content:site-chrome`: those entities are\n // `kind: 'singleton'` and `AdminClient.create` already runs the cardinality\n // check under SERIALIZABLE with a retry, so its loser gets a clear\n // `SingletonCardinalityError` rather than an opaque code.\n if (!isUniqueViolation(err)) throw err\n return { created: 0 }\n }\n\n return { created: 1 }\n}\n"],"mappings":"6LAkGA,SAAS,EAAkB,EAAuB,CAChD,IAAM,EAAO,IAAI,QACb,EAAS,EACb,KAAO,OAAO,GAAW,UAAY,GAAiB,CACpD,GAAI,EAAK,IAAI,CAAM,EAAG,MAAO,GAC7B,EAAK,IAAI,CAAM,EACf,IAAM,EAAI,EACV,GAAI,OAAO,EAAE,MAAS,UAAY,EAAE,OAAS,QAAS,MAAO,GAC7D,EAAS,EAAE,KACb,CACA,MAAO,EACT,CAGA,MAAM,EAAc,OA0BpB,eAAe,EAAiB,EAA2D,CACzF,GAAI,CAAC,EAAY,MAAO,CAAC,EACzB,IAAM,EAAS,EAAO,CAAC,CAAC,SAAS,IAAI,CAAU,EAC/C,GAAI,CAAC,EAAQ,MAAO,CAAC,EAErB,IAAM,EAAS,EAAkB,CAAM,EAKjC,EAAQ,EAAO,SAAS,EACxB,EAAS,EAAM,OACf,EAAY,EAAM,UAClB,EAAK,EAAM,GACjB,GAAI,CAAC,GAAU,CAAC,GAAa,CAAC,EAAI,MAAO,CAAC,EAC1C,IAAM,EAAY,EACZ,EAAe,EACf,EAAQ,EAoBd,OAAO,MANY,EAAO,SAAS,CACjC,MAAO,EAAG,EAAW,WAAW,EAChC,QAAS,CAAC,EAAI,CAAY,EAAG,EAAI,CAAK,CAAC,EACvC,MAAO,CACT,CAAC,EAAA,CAEW,IAAK,IAAS,CAIxB,GAAI,OAAO,WAAW,EACtB,KAAM,OACN,KAAM,CAAE,KAAM,SAAU,OAAQ,EAAY,GAAI,EAAI,EAAa,EAKjE,MAAO,KACP,SAAU,CAAC,CACb,EAAE,CACJ,CAeA,eAAsB,EAAa,EAEF,CAC/B,IAAM,EAAa,EAAkB,CAAI,EAQnC,EADY,EAAW,SACN,CAAC,CAAC,OASzB,GAAI,CAAC,EACH,MAAU,MACR,6LAGF,EAEF,IAAM,EAAY,EAKlB,IAAI,MAJmB,EAAW,SAAS,CACzC,MAAO,EAAG,EAAW,CAAW,EAChC,MAAO,CACT,CAAC,EAAA,CACY,OAAS,EAAG,MAAO,CAAE,QAAS,CAAE,EAE7C,IAAM,EAAO,MAAM,EAAiB,GAAS,aAAa,EAE1D,GAAI,CACF,MAAM,EAAW,OAAO,CACtB,OAAQ,EACR,MAAO,kBACP,OAKA,OAAQ,YAIR,YAAa,IAAI,IACnB,CAAC,CACH,OAAS,EAAK,CAkBZ,GAAI,CAAC,EAAkB,CAAG,EAAG,MAAM,EACnC,MAAO,CAAE,QAAS,CAAE,CACtB,CAEA,MAAO,CAAE,QAAS,CAAE,CACtB"}