@tristan2828/ui-foundation 4.8.0 → 4.9.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tristan2828/ui-foundation",
3
- "version": "4.8.0",
3
+ "version": "4.9.0",
4
4
  "description": "Shared UI foundation for contract-first, database-backed React apps: shadcn primitives, DataTable/EntityForm composites, auth, the gateway error seam, design tokens, lint rules and the AI playbook.",
5
5
  "license": "MIT",
6
6
  "repository": {
package/vite/index.d.ts CHANGED
@@ -3,12 +3,24 @@ import type { Plugin } from 'vite'
3
3
  export type BuildInfoOptions = {
4
4
  /** The app's repository, e.g. `https://github.com/me/app`. `BUILD_REPO_URL` wins over it. */
5
5
  repoUrl?: string
6
+ /**
7
+ * The Alembic versions folder, from the Vite root (`backend/migrations/versions`).
8
+ * Adds `schema`, the head revision, to `build-info.json`; no head or
9
+ * several fails the build.
10
+ */
11
+ alembic?: string
12
+ /**
13
+ * More fields for `build-info.json`, merged in. `api`, `commit`, `pr`,
14
+ * `builtAt` and `schema` are the plugin's own: returning one fails the build.
15
+ */
16
+ extra?: () => Record<string, unknown>
6
17
  }
7
18
 
8
19
  /**
9
20
  * On `vite build`, writes `dist/build-info.json` (`api`: "real" or "mock",
10
- * `commit`, `pr`, `builtAt`) and sets `import.meta.env.VITE_BUILD_COMMIT`,
11
- * `VITE_BUILD_PR` and `VITE_BUILD_REPO_URL` from `BUILD_COMMIT`,
12
- * `BUILD_PR` and `BUILD_REPO_URL`.
21
+ * `commit`, `pr`, `builtAt`, and `schema` with `alembic`) and sets
22
+ * `import.meta.env.VITE_BUILD_COMMIT`, `VITE_BUILD_PR` and
23
+ * `VITE_BUILD_REPO_URL` from `BUILD_COMMIT`, `BUILD_PR` and
24
+ * `BUILD_REPO_URL`.
13
25
  */
14
26
  export default function buildInfo(options?: BuildInfoOptions): Plugin
package/vite/index.js CHANGED
@@ -13,21 +13,72 @@
13
13
  // BUILD_REPO_URL (the repoUrl option when that's unset), for AppShell's
14
14
  // `version`. Unset, they're empty strings and nothing is shown.
15
15
  //
16
+ // - with `alembic` (the migrations' versions folder, from the Vite root),
17
+ // adds `schema`: the head revision the backend migrates to. Read from the
18
+ // files, so the build needs no Python. No head, or several, fails the
19
+ // build, as `alembic upgrade head` would refuse at start.
20
+ // - with `extra`, merges what it returns into the file. It can't replace the
21
+ // fields above: the backend's production check reads `api`.
22
+ //
16
23
  // Build only: in development the three are undefined. Plain JavaScript, like
17
24
  // the eslint entry: Vite loads the config before any TypeScript tooling runs.
18
25
 
26
+ import fs from 'node:fs'
27
+ import path from 'node:path'
28
+
29
+ const OWN_FIELDS = ['api', 'commit', 'pr', 'builtAt', 'schema']
30
+
19
31
  /** @param {string | undefined} value */
20
32
  function clean(value) {
21
33
  const trimmed = value?.trim()
22
34
  return trimmed ? trimmed : null
23
35
  }
24
36
 
25
- /** @param {{ repoUrl?: string }} [options] */
37
+ // `revision = "abc"` or `revision: str = "abc"`, as Alembic writes them.
38
+ const REVISION = /^revision(?:\s*:[^=\n]+)?\s*=\s*(['"])([^'"]+)\1/m
39
+ // None, one revision, or a tuple of them (a merge), possibly over lines.
40
+ const DOWN_REVISION = /^down_revision(?:\s*:[^=\n]+)?\s*=\s*(\([^)]*\)|[^\n]*)/m
41
+
42
+ /**
43
+ * The one revision no other migration names as its down_revision.
44
+ * @param {string} dir
45
+ */
46
+ function alembicHead(dir) {
47
+ if (!fs.existsSync(dir)) throw new Error(`buildInfo: no Alembic versions folder at ${dir}.`)
48
+ const revisions = new Set()
49
+ const downs = new Set()
50
+ for (const file of fs.readdirSync(dir).filter((name) => name.endsWith('.py'))) {
51
+ const source = fs.readFileSync(path.join(dir, file), 'utf8')
52
+ const revision = REVISION.exec(source)
53
+ if (!revision) continue
54
+ revisions.add(revision[2])
55
+ const down = DOWN_REVISION.exec(source)?.[1] ?? ''
56
+ for (const [, , name] of down.matchAll(/(['"])([^'"]+)\1/g)) downs.add(name)
57
+ }
58
+ const heads = [...revisions].filter((revision) => !downs.has(revision)).sort()
59
+ if (heads.length === 1) return heads[0]
60
+ throw new Error(
61
+ heads.length === 0
62
+ ? `buildInfo: no Alembic head in ${dir} (no migrations, or they form a loop).`
63
+ : `buildInfo: several Alembic heads in ${dir} (${heads.join(', ')}); alembic upgrade head would refuse. Merge them first.`,
64
+ )
65
+ }
66
+
67
+ /**
68
+ * @param {{
69
+ * repoUrl?: string,
70
+ * alembic?: string,
71
+ * extra?: () => Record<string, unknown>,
72
+ * }} [options]
73
+ */
26
74
  export default function buildInfo(options = {}) {
27
75
  const commit = clean(process.env.BUILD_COMMIT)
28
76
  const pr = clean(process.env.BUILD_PR)
29
77
  const repoUrl = clean(process.env.BUILD_REPO_URL) ?? clean(options.repoUrl)
30
78
  let api = 'mock'
79
+ let root = process.cwd()
80
+ /** @type {Record<string, unknown>} */
81
+ let fields = {}
31
82
 
32
83
  return {
33
84
  name: 'ui-foundation:build-info',
@@ -44,12 +95,26 @@ export default function buildInfo(options = {}) {
44
95
  configResolved(config) {
45
96
  // The same test as the app's main.tsx: anything but "real" ships MSW.
46
97
  api = config.env.VITE_API === 'real' ? 'real' : 'mock'
98
+ root = config.root
99
+ },
100
+ // Read before bundling, so a bad migration chain fails the build early.
101
+ buildStart() {
102
+ fields = {}
103
+ if (options.alembic) fields.schema = alembicHead(path.resolve(root, options.alembic))
104
+ if (options.extra) {
105
+ const extra = options.extra()
106
+ const taken = Object.keys(extra).filter((key) => OWN_FIELDS.includes(key))
107
+ if (taken.length > 0) {
108
+ throw new Error(`buildInfo: extra can't set ${taken.join(', ')}; build-info.json's own fields.`)
109
+ }
110
+ Object.assign(fields, extra)
111
+ }
47
112
  },
48
113
  generateBundle() {
49
114
  this.emitFile({
50
115
  type: 'asset',
51
116
  fileName: 'build-info.json',
52
- source: `${JSON.stringify({ api, commit, pr, builtAt: new Date().toISOString() }, null, 2)}\n`,
117
+ source: `${JSON.stringify({ api, commit, pr, builtAt: new Date().toISOString(), ...fields }, null, 2)}\n`,
53
118
  })
54
119
  },
55
120
  }