@clubedge/create-clubedge-app 0.48.0 → 0.48.1
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/CHANGELOG.md +9 -0
- package/README.md +12 -10
- package/dist/core/package-json-merge.js +40 -0
- package/dist/core/patches.js +140 -0
- package/dist/core/project.js +3 -5
- package/dist/modify.js +33 -1
- package/package.json +3 -3
- package/template/apps/api/package.json +1 -1
- package/template/apps/api-fastapi/package.json +1 -1
- package/template/apps/api-fastapi/pyproject.toml +1 -1
- package/template/apps/api-fastapi/src/clubedge_api/users.py +12 -7
- package/template/apps/api-fastapi/tests/test_users.py +19 -1
- package/template/apps/api-fastapi/uv.lock +1 -1
- package/template/apps/capacitor/package.json +1 -1
- package/template/apps/expo/package.json +1 -1
- package/template/apps/start/package.json +1 -1
- package/template/apps/vite/package.json +1 -1
- package/template/apps/web/package.json +1 -1
- package/template/deploy/cloudflare/package.json +1 -1
- package/template/package.json +1 -1
- package/template/packages/auth/package.json +1 -1
- package/template/packages/auth-appwrite/package.json +1 -1
- package/template/packages/auth-better-auth/package.json +1 -1
- package/template/packages/auth-firebase/package.json +1 -1
- package/template/packages/auth-supabase/package.json +1 -1
- package/template/packages/cache/package.json +1 -1
- package/template/packages/cache-redis/package.json +1 -1
- package/template/packages/contract/package.json +1 -1
- package/template/packages/core/package.json +1 -1
- package/template/packages/db/package.json +1 -1
- package/template/packages/db/src/users.test.ts +54 -1
- package/template/packages/db/src/users.ts +62 -15
- package/template/packages/email/package.json +1 -1
- package/template/packages/email-resend/package.json +1 -1
- package/template/packages/email-smtp/package.json +1 -1
- package/template/packages/jobs/package.json +1 -1
- package/template/packages/jobs-bullmq/package.json +1 -1
- package/template/packages/jobs-inngest/package.json +1 -1
- package/template/packages/jobs-pg-boss/package.json +1 -1
- package/template/packages/local-db/package.json +1 -1
- package/template/packages/mobile/package.json +1 -1
- package/template/packages/observability/package.json +1 -1
- package/template/packages/observability-otel/package.json +1 -1
- package/template/packages/observability-sentry/package.json +1 -1
- package/template/packages/payments/package.json +1 -1
- package/template/packages/payments-stripe/package.json +1 -1
- package/template/packages/push/package.json +1 -1
- package/template/packages/push-expo/package.json +1 -1
- package/template/packages/push-native/package.json +1 -1
- package/template/packages/storage/package.json +1 -1
- package/template/packages/storage-s3/package.json +1 -1
- package/template/packages/storage-supabase/package.json +1 -1
- package/template/packages/ui/package.json +1 -1
- package/template.lock.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `create-clubedge-app` are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.48.1
|
|
6
|
+
|
|
7
|
+
- **Starter v0.31.2** by default (#214). Its template schema is still 6. The [Starter's release notes](https://github.com/Clubedge/clubedge-starter/releases/tag/v0.31.2) cover:
|
|
8
|
+
- a new user's first requests at once, which get one users row instead of a 500 for all but one, in the Hono API, the FastAPI API, and the websites; a sync of the profile to an email address another user's row has still fails.
|
|
9
|
+
- **Projects from Starter releases before v0.5.0 upgrade** (#206; issue #205). They record no modules, since those releases had none, and the CLI refused them as "created before create-clubedge-app 0.5.0", though 0.48.0 had created them. An upgrade now keeps Supabase Auth, S3 storage, and the Redis cache, which those releases built in and v0.5.0 made its first modules, and a note says so; modules that came later start as `none`. `add` and `remove` on such a project say to upgrade first.
|
|
10
|
+
- **A dependency you only raised within its range, as `pnpm up` does, takes the Starter's new major or its removal** (#208; issue #207). After `pnpm up -r`, every upgrade to Starter v0.31.1 left a `package.json` conflict pnpm cannot read, such as `redis` `^5.12.2` against the Starter's `^6.3.1`, or a dependency the Starter moved to another package. A change that asks for less than your raise, such as a pin below it, still conflicts, and a range both sides raised still takes the higher one.
|
|
11
|
+
- **A `pnpm patch` the change leaves unused is named before it stops `pnpm install`** (#210; issue #209). A patch made on a version the change moves away from stops the install with `ERR_PNPM_UNUSED_PATCH`. The CLI names it, with the range that leaves it out, skips the install, and says how to keep it: keyed by the package alone, pnpm applies it to whichever version it installs.
|
|
12
|
+
- CI upgrades a project from Starter v0.4.0 and matches it against a fresh one (#206), and the tests that commit to Git have more time on Windows (#210).
|
|
13
|
+
|
|
5
14
|
## 0.48.0
|
|
6
15
|
|
|
7
16
|
- **Starter v0.31.1** by default (#202). Its template schema is still 6. The [Starter's release notes](https://github.com/Clubedge/clubedge-starter/releases/tag/v0.31.1) cover:
|
package/README.md
CHANGED
|
@@ -246,7 +246,7 @@ pnpm dlx @clubedge/create-clubedge-app my-app --yes --no-git --framework tanstac
|
|
|
246
246
|
pnpm dlx @clubedge/create-clubedge-app my-app --ref main
|
|
247
247
|
|
|
248
248
|
# Reproduce a specific Starter release or commit
|
|
249
|
-
pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.31.
|
|
249
|
+
pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.31.2
|
|
250
250
|
pnpm dlx @clubedge/create-clubedge-app my-app --ref <commit-sha>
|
|
251
251
|
|
|
252
252
|
# Test local Starter changes before releasing them
|
|
@@ -267,7 +267,7 @@ pnpm clubedge add mobile expo # the native app; ad
|
|
|
267
267
|
pnpm clubedge remove mobile
|
|
268
268
|
pnpm dlx @clubedge/create-clubedge-app add infra local --dry-run
|
|
269
269
|
pnpm dlx @clubedge/create-clubedge-app@latest upgrade # to the newest CLI's Starter release
|
|
270
|
-
pnpm dlx @clubedge/create-clubedge-app upgrade --ref v0.31.
|
|
270
|
+
pnpm dlx @clubedge/create-clubedge-app upgrade --ref v0.31.2 # or a specific tag, branch, or commit
|
|
271
271
|
```
|
|
272
272
|
|
|
273
273
|
The CLI renders the project twice, from the Starter commit and modules recorded in your `package.json` and from the new state, and merges the difference into your project. The new state has other modules for `add` and `remove`, and the newer Starter with the same modules for `upgrade`. Modules a newer Starter introduces start as `none` where possible, so an upgrade never adds a service on its own.
|
|
@@ -275,6 +275,8 @@ The CLI renders the project twice, from the Starter commit and modules recorded
|
|
|
275
275
|
- Files you never changed are updated, added, or deleted.
|
|
276
276
|
- Files you changed keep your edits. Where the module change touches the same lines, the file gets conflict markers (`<<<<<<< yours` ... `>>>>>>> clubedge`) for you to resolve. The Starter's whole new version of each such file is saved in `.clubedge/incoming/` to compare with yours (`git diff --no-index <file> .clubedge/incoming/<file>`); Git ignores that folder, so delete it when you are done.
|
|
277
277
|
- `package.json` files merge by key, not by line: a script, dependency, or override you added next to the Starter's keeps both, and markers wrap only a key you both changed to different values, such as the same dependency's range. The file keeps your key order, indentation, and line endings.
|
|
278
|
+
- A dependency's range that only one side raised within itself, as `pnpm up` does, gives way to the other side's change when that removes the dependency or asks for at least the raised version: the Starter's move to a new major, or its removal of a dependency it moved to another package, over your `pnpm up -r`. A range both sides raised within itself takes the higher one.
|
|
279
|
+
- A `pnpm patch` made on a version the change moves away from would stop `pnpm install` (`ERR_PNPM_UNUSED_PATCH`). The CLI names each such patch and the range that leaves it out, and skips the install until it is fixed: keyed by the package alone, pnpm applies a patch to whichever version it installs.
|
|
278
280
|
- Your own files are never touched.
|
|
279
281
|
- Each app's local env file (`apps/web/.env.local`, and `apps/api/.env.local`, `apps/shell/.env.local`, or `apps/mobile/.env.local` when the project has them) keeps your values. Values still at their generated defaults follow the new selection, and new variables are added.
|
|
280
282
|
- `pnpm-lock.yaml` is replaced if you never changed your dependencies; otherwise `pnpm install` reconciles it.
|
|
@@ -416,27 +418,27 @@ Projects created with `--infra neon` also have `.github/workflows/preview.yml`,
|
|
|
416
418
|
Each CLI release is tested against one specific Starter release:
|
|
417
419
|
|
|
418
420
|
```text
|
|
419
|
-
create-clubedge-app v0.48.
|
|
421
|
+
create-clubedge-app v0.48.1
|
|
420
422
|
|
|
|
421
423
|
v
|
|
422
|
-
Clubedge Starter v0.31.
|
|
423
|
-
commit
|
|
424
|
+
Clubedge Starter v0.31.2
|
|
425
|
+
commit 2635acabc29de2eb3656bc9e6741eac8bd72cd38
|
|
424
426
|
```
|
|
425
427
|
|
|
426
428
|
The current relationship is:
|
|
427
429
|
|
|
428
430
|
| CLI release | Default Starter release | Meaning |
|
|
429
431
|
| --- | --- | --- |
|
|
430
|
-
| `create-clubedge-app@0.48.
|
|
432
|
+
| `create-clubedge-app@0.48.1` | `clubedge-starter@v0.31.2` | The CLI's required, tested default |
|
|
431
433
|
|
|
432
434
|
This means the same CLI version always scaffolds the same Starter revision by default. The CLI does not silently follow Starter `main`.
|
|
433
435
|
|
|
434
|
-
The `v0.31.
|
|
435
|
-
`
|
|
436
|
+
The `v0.31.2` tag is expected to resolve to commit
|
|
437
|
+
`2635acabc29de2eb3656bc9e6741eac8bd72cd38`. The CLI verifies this before
|
|
436
438
|
scaffolding with the default reference and fails rather than silently using a
|
|
437
439
|
moved tag.
|
|
438
440
|
|
|
439
|
-
When the Starter changes, its maintainers first publish a new versioned release, such as `v0.31.
|
|
441
|
+
When the Starter changes, its maintainers first publish a new versioned release, such as `v0.31.2`. The CLI is then updated to use that release, all generated-project checks must pass, and a new CLI version is published. This keeps Starter and CLI releases independently versioned while preserving a tested compatibility relationship.
|
|
440
442
|
|
|
441
443
|
Every generated project records its CLI version, Starter repository, Starter ref, Starter commit, framework, and modules in `package.json` and `README.md`.
|
|
442
444
|
|
|
@@ -491,7 +493,7 @@ CI packs the CLI with `npm pack`, scaffolds clean projects from the packed tarba
|
|
|
491
493
|
1. Make and validate changes in `clubedge-starter`.
|
|
492
494
|
2. Run lint, typecheck, unit tests, browser tests, and production build.
|
|
493
495
|
3. Update the Starter package versions.
|
|
494
|
-
4. Commit `main`, create a release tag such as `v0.31.
|
|
496
|
+
4. Commit `main`, create a release tag such as `v0.31.2`, and publish the Starter release.
|
|
495
497
|
|
|
496
498
|
### CLI maintainers
|
|
497
499
|
|
|
@@ -61,10 +61,38 @@ function higherRange(a, b) {
|
|
|
61
61
|
return n > (y[i] ?? 0) ? a : b;
|
|
62
62
|
return a;
|
|
63
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* Whether `range` raises `base` within the versions `base` already allowed, as `pnpm up` does:
|
|
66
|
+
* ^5.8.0 to ^5.12.2. A raise like that only takes newer releases, so it is no edit of its own.
|
|
67
|
+
*/
|
|
68
|
+
const raisedWithin = (base, range) => typeof base === "string" && typeof range === "string" && range !== base && higherRange(base, range) === range;
|
|
69
|
+
/** Whether a dependency map has the base's dependencies, each the same or only raised within its range. */
|
|
70
|
+
const onlyRaised = (base, map) => isObject(base) &&
|
|
71
|
+
isObject(map) &&
|
|
72
|
+
Object.keys(map).length === Object.keys(base).length &&
|
|
73
|
+
Object.entries(map).every(([key, range]) => Object.hasOwn(base, key) && (same(base[key], range) || raisedWithin(base[key], range)));
|
|
74
|
+
/** The lowest version a ^ or ~ range, or an exact version, allows; undefined for anything else. */
|
|
75
|
+
function minimum(range) {
|
|
76
|
+
if (typeof range !== "string")
|
|
77
|
+
return undefined;
|
|
78
|
+
const match = SIMPLE_RANGE.exec(range) ?? /^()(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.exec(range);
|
|
79
|
+
return match ? match.slice(2).map(Number) : undefined;
|
|
80
|
+
}
|
|
81
|
+
/** Whether `range` asks for at least the lowest version `than` allows: ^6.3.1 than ^5.12.2. */
|
|
82
|
+
function atLeast(range, than) {
|
|
83
|
+
const [a, b] = [minimum(range), minimum(than)];
|
|
84
|
+
if (!a || !b)
|
|
85
|
+
return false;
|
|
86
|
+
const differs = a.findIndex((n, i) => n !== b[i]);
|
|
87
|
+
return differs === -1 || a[differs] > b[differs];
|
|
88
|
+
}
|
|
64
89
|
/**
|
|
65
90
|
* One key's value, at `path` from the file's root: whoever changed it, or a conflict when both
|
|
66
91
|
* did, differently. Objects both changed merge key by key; arrays and scalars are single values.
|
|
67
92
|
* Undefined is a removed key. A dependency both raised within its range takes the higher range.
|
|
93
|
+
* One that a side only raised within its range, as `pnpm up` does, takes the other side's change
|
|
94
|
+
* when that removes it or asks for at least the raised version: the Starter's removal of a
|
|
95
|
+
* dependency, or its new major, over the project's `pnpm up`, and the other way round.
|
|
68
96
|
*/
|
|
69
97
|
function merge(base, yours, theirs, path) {
|
|
70
98
|
if (same(yours, theirs) || same(base, theirs))
|
|
@@ -73,6 +101,18 @@ function merge(base, yours, theirs, path) {
|
|
|
73
101
|
return theirs;
|
|
74
102
|
if (isObject(yours) && isObject(theirs))
|
|
75
103
|
return mergeObjects(isObject(base) ? base : {}, yours, theirs, path);
|
|
104
|
+
if (holdsRanges(path.slice(0, -1))) {
|
|
105
|
+
const yoursRaised = raisedWithin(base, yours);
|
|
106
|
+
if (yoursRaised !== raisedWithin(base, theirs)) {
|
|
107
|
+
const [raised, other] = yoursRaised ? [yours, theirs] : [theirs, yours];
|
|
108
|
+
if (other === undefined || atLeast(other, raised))
|
|
109
|
+
return other;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// A whole dependency map one side only raised within its ranges, removed by the other side.
|
|
113
|
+
if (holdsRanges(path) && ((theirs === undefined && onlyRaised(base, yours)) || (yours === undefined && onlyRaised(base, theirs)))) {
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
76
116
|
if (typeof yours === "string" && typeof theirs === "string" && holdsRanges(path.slice(0, -1))) {
|
|
77
117
|
const higher = higherRange(yours, theirs);
|
|
78
118
|
if (higher !== undefined)
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// pnpm stops `pnpm install` on a patch it does not use (ERR_PNPM_UNUSED_PATCH). `pnpm patch-commit`
|
|
2
|
+
// keys a patch by the exact version it was made on, so a change that moves the dependency to a
|
|
3
|
+
// range without that version leaves the patch unused, and the install stops. These find such
|
|
4
|
+
// patches once the change is written, so the CLI can say which and what to do, instead of pnpm
|
|
5
|
+
// stopping on them.
|
|
6
|
+
import { readFile } from "node:fs/promises";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { listTemplateFiles, pathExists } from "../utils/fs.js";
|
|
9
|
+
const VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
|
|
10
|
+
const RANGE = /^([\^~])(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
|
|
11
|
+
/**
|
|
12
|
+
* Whether a range allows a version: an exact version, or a ^ or ~ range on a full version.
|
|
13
|
+
* Undefined for any other range, which this cannot tell.
|
|
14
|
+
*/
|
|
15
|
+
export function allows(range, version) {
|
|
16
|
+
const wanted = VERSION.exec(version)?.slice(1).map(Number);
|
|
17
|
+
if (!wanted)
|
|
18
|
+
return undefined;
|
|
19
|
+
const exact = VERSION.exec(range);
|
|
20
|
+
if (exact)
|
|
21
|
+
return exact[0] === version;
|
|
22
|
+
const match = RANGE.exec(range);
|
|
23
|
+
if (!match)
|
|
24
|
+
return undefined;
|
|
25
|
+
const floor = match.slice(2).map(Number);
|
|
26
|
+
// How many numbers the range keeps: ~ the major and minor, ^ up to its first one that is not 0.
|
|
27
|
+
const kept = match[1] === "~" ? 2 : floor.findIndex((n) => n !== 0) + 1 || 3;
|
|
28
|
+
if (floor.slice(0, kept).some((n, i) => n !== wanted[i]))
|
|
29
|
+
return false;
|
|
30
|
+
const differs = floor.findIndex((n, i) => n !== wanted[i]);
|
|
31
|
+
return differs === -1 || wanted[differs] > floor[differs];
|
|
32
|
+
}
|
|
33
|
+
/** `zod@4.6.5` or `@scope/pkg@1.2.3` into the package and its version; null for a key without an exact one. */
|
|
34
|
+
function splitKey(key) {
|
|
35
|
+
const at = key.lastIndexOf("@");
|
|
36
|
+
if (at <= 0)
|
|
37
|
+
return null;
|
|
38
|
+
const [name, version] = [key.slice(0, at), key.slice(at + 1)];
|
|
39
|
+
return VERSION.test(version) ? { name, version } : null;
|
|
40
|
+
}
|
|
41
|
+
/** The patchedDependencies block of pnpm-workspace.yaml: indented `key: path` lines after its own line. */
|
|
42
|
+
function workspacePatches(yaml) {
|
|
43
|
+
const lines = yaml.split(/\r?\n/);
|
|
44
|
+
const start = lines.findIndex((line) => /^patchedDependencies:\s*(#.*)?$/.test(line));
|
|
45
|
+
if (start === -1)
|
|
46
|
+
return [];
|
|
47
|
+
const entries = [];
|
|
48
|
+
for (const line of lines.slice(start + 1)) {
|
|
49
|
+
if (!line.trim() || line.trim().startsWith("#"))
|
|
50
|
+
continue;
|
|
51
|
+
if (!/^\s/.test(line))
|
|
52
|
+
break;
|
|
53
|
+
const entry = /^\s+(['"]?)(.+?)\1\s*:\s*(['"]?)(.+?)\3\s*(#.*)?$/.exec(line);
|
|
54
|
+
if (entry)
|
|
55
|
+
entries.push([entry[2], entry[4]]);
|
|
56
|
+
}
|
|
57
|
+
return entries;
|
|
58
|
+
}
|
|
59
|
+
/** Every patch the project declares, with the file that declares it. */
|
|
60
|
+
async function declaredPatches(root) {
|
|
61
|
+
const patches = [];
|
|
62
|
+
try {
|
|
63
|
+
const manifest = JSON.parse(await readFile(join(root, "package.json"), "utf8"));
|
|
64
|
+
for (const [key, patch] of Object.entries(manifest.pnpm?.patchedDependencies ?? {})) {
|
|
65
|
+
if (typeof patch === "string")
|
|
66
|
+
patches.push({ key, patch, file: "package.json" });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// An unreadable package.json is reported elsewhere.
|
|
71
|
+
}
|
|
72
|
+
if (await pathExists(join(root, "pnpm-workspace.yaml"))) {
|
|
73
|
+
for (const [key, patch] of workspacePatches(await readFile(join(root, "pnpm-workspace.yaml"), "utf8"))) {
|
|
74
|
+
patches.push({ key, patch, file: "pnpm-workspace.yaml" });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return patches;
|
|
78
|
+
}
|
|
79
|
+
/** Each package's ranges across the workspace's package.json files; an override replaces them. */
|
|
80
|
+
async function dependencyRanges(root) {
|
|
81
|
+
const ranges = new Map();
|
|
82
|
+
const overrides = {};
|
|
83
|
+
for (const path of (await listTemplateFiles(root)).filter((file) => /(^|\/)package\.json$/.test(file))) {
|
|
84
|
+
let manifest;
|
|
85
|
+
try {
|
|
86
|
+
manifest = JSON.parse(await readFile(join(root, path), "utf8"));
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (path === "package.json")
|
|
92
|
+
Object.assign(overrides, manifest.pnpm?.overrides ?? {});
|
|
93
|
+
for (const field of ["dependencies", "devDependencies", "optionalDependencies"]) {
|
|
94
|
+
for (const [name, range] of Object.entries(manifest[field] ?? {})) {
|
|
95
|
+
if (typeof range === "string")
|
|
96
|
+
ranges.set(name, [...(ranges.get(name) ?? []), range]);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
for (const [name, range] of Object.entries(overrides)) {
|
|
101
|
+
if (typeof range === "string" && ranges.has(name))
|
|
102
|
+
ranges.set(name, [range]);
|
|
103
|
+
}
|
|
104
|
+
return ranges;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Patches keyed by an exact version that none of the project's ranges for the package allows,
|
|
108
|
+
* which pnpm would leave unused. A patch on a package no package.json names (a dependency of a
|
|
109
|
+
* dependency), or with a range this cannot read, is left out: pnpm decides those.
|
|
110
|
+
*/
|
|
111
|
+
export async function unusedPatches(root) {
|
|
112
|
+
const declared = await declaredPatches(root);
|
|
113
|
+
if (!declared.length)
|
|
114
|
+
return [];
|
|
115
|
+
const ranges = await dependencyRanges(root);
|
|
116
|
+
const unused = [];
|
|
117
|
+
for (const { key, patch, file } of declared) {
|
|
118
|
+
const parsed = splitKey(key);
|
|
119
|
+
const own = parsed ? ranges.get(parsed.name) : undefined;
|
|
120
|
+
if (!parsed || !own?.length)
|
|
121
|
+
continue;
|
|
122
|
+
if (own.every((range) => allows(range, parsed.version) === false))
|
|
123
|
+
unused.push({ key, ...parsed, patch, file, ranges: own });
|
|
124
|
+
}
|
|
125
|
+
return unused;
|
|
126
|
+
}
|
|
127
|
+
/** What to tell the developer about patches pnpm would not use, and how to keep them. */
|
|
128
|
+
export function unusedPatchWarning(patches, installSkipped) {
|
|
129
|
+
const lines = patches.map(({ key, name, patch, file, ranges }) => ` ${key} (${patch}, in ${file}): the project now asks for ${name} ${[...new Set(ranges)].join(" or ")}`);
|
|
130
|
+
const [first] = patches;
|
|
131
|
+
// The key by the package alone, written as its file writes keys.
|
|
132
|
+
const byName = first.file === "package.json"
|
|
133
|
+
? `"${first.name}": "${first.patch}"`
|
|
134
|
+
: `${first.name.startsWith("@") ? `'${first.name}'` : first.name}: ${first.patch}`;
|
|
135
|
+
return [
|
|
136
|
+
`${installSkipped ? "Skipped `pnpm install`: it would stop" : "`pnpm install` will stop"} on ${patches.length === 1 ? "a patch" : "patches"} made for a version the change moves away from (ERR_PNPM_UNUSED_PATCH):`,
|
|
137
|
+
...lines,
|
|
138
|
+
`To try ${patches.length === 1 ? "it" : "each"} on the new version, key it by the package alone in ${first.file}, as \`${byName}\`: pnpm then applies it to the version it installs, and says so if it no longer fits. Or make it again with \`pnpm patch ${first.name}\`. Then run \`pnpm install\`.`,
|
|
139
|
+
].join("\n");
|
|
140
|
+
}
|
package/dist/core/project.js
CHANGED
|
@@ -57,10 +57,8 @@ export async function readProject(directory) {
|
|
|
57
57
|
}
|
|
58
58
|
const origin = manifest.clubedge;
|
|
59
59
|
if (origin?.starterRef) {
|
|
60
|
-
// Projects record the framework only when the Starter offered a choice,
|
|
61
|
-
|
|
62
|
-
throw new ProjectError("This project was created before create-clubedge-app 0.5.0 and does not record its modules, so it cannot be changed automatically.");
|
|
63
|
-
}
|
|
60
|
+
// Projects record the framework only when the Starter offered a choice, and their modules
|
|
61
|
+
// when it had any: a project from Starter v0.4.0 or earlier records none.
|
|
64
62
|
return {
|
|
65
63
|
root: current,
|
|
66
64
|
identity: {
|
|
@@ -73,7 +71,7 @@ export async function readProject(directory) {
|
|
|
73
71
|
starterRef: origin.starterRef,
|
|
74
72
|
starterCommit: origin.starterCommit,
|
|
75
73
|
stacks: recordedStacks(origin),
|
|
76
|
-
modules: origin.modules,
|
|
74
|
+
modules: origin.modules && typeof origin.modules === "object" ? origin.modules : {},
|
|
77
75
|
services: origin.services && typeof origin.services === "object" ? origin.services : {},
|
|
78
76
|
};
|
|
79
77
|
}
|
package/dist/modify.js
CHANGED
|
@@ -12,6 +12,7 @@ import { runsServer } from "./core/manifest-fields.js";
|
|
|
12
12
|
import { readTemplateManifest } from "./core/manifest.js";
|
|
13
13
|
import { compareVersions } from "./core/health.js";
|
|
14
14
|
import { countConflicts } from "./core/merge.js";
|
|
15
|
+
import { unusedPatches, unusedPatchWarning } from "./core/patches.js";
|
|
15
16
|
import { describeReleases, fetchReleases, releasesBetween } from "./core/releases.js";
|
|
16
17
|
import { moduleChoices, NO_STACK, selectModules, selectStacks, slotChoices, stacksIn } from "./core/plan.js";
|
|
17
18
|
import { readProject } from "./core/project.js";
|
|
@@ -319,6 +320,12 @@ export function changedSelection(manifest, project, command, cli) {
|
|
|
319
320
|
export function changedModules(manifest, project, command, cli) {
|
|
320
321
|
return changedSelection(manifest, project, command, cli).modules;
|
|
321
322
|
}
|
|
323
|
+
/**
|
|
324
|
+
* What Starters before v0.5.0 built in: Supabase Auth, S3 storage, and a Redis cache. v0.5.0 made
|
|
325
|
+
* them its first modules, with these options as defaults. Projects from those Starters record no
|
|
326
|
+
* modules, and an upgrade keeps these rather than taking them out as modules the project predates.
|
|
327
|
+
*/
|
|
328
|
+
export const BUILT_IN_BEFORE_MODULES = { auth: "supabase", storage: "s3", cache: "redis" };
|
|
322
329
|
/**
|
|
323
330
|
* The project's selection in a newer Starter. Modules the project predates get their "none"
|
|
324
331
|
* option where there is one, so an upgrade never adds a service on its own.
|
|
@@ -476,8 +483,16 @@ async function planChange(project, command, base, baseManifest, target, cli) {
|
|
|
476
483
|
if (target.commit && target.commit === project.starterCommit)
|
|
477
484
|
return null;
|
|
478
485
|
const targetManifest = await readTemplateManifest(target.root);
|
|
479
|
-
|
|
486
|
+
// A Starter before modules built in what became the first ones, and its project keeps them.
|
|
487
|
+
const builtIn = !Object.keys(baseManifest.modules).length && Object.keys(targetManifest.modules).length > 0;
|
|
488
|
+
const keptModules = Object.fromEntries(Object.entries(BUILT_IN_BEFORE_MODULES).filter(([id]) => targetManifest.modules[id]));
|
|
489
|
+
const recorded = builtIn ? { ...project, modules: keptModules } : project;
|
|
490
|
+
const { modules, introduced, dropped } = upgradedModules(targetManifest, recorded, cli);
|
|
480
491
|
const notes = dropped.map((id) => `Starter ${target.ref} no longer has the ${id} module; its files are merged away like any other change.`);
|
|
492
|
+
if (builtIn && Object.keys(keptModules).length) {
|
|
493
|
+
const kept = Object.entries(keptModules).map(([id, option]) => `${targetManifest.modules[id].name}: ${targetManifest.modules[id].options[option]?.name ?? option}`);
|
|
494
|
+
notes.unshift(`Starter ${project.starterRef} had no modules yet, but built in what became the first ones, so the project keeps them: ${kept.join(", ")}. Switch one with: ${cli} add <module> <option>`);
|
|
495
|
+
}
|
|
481
496
|
if (/^v\d/.test(target.ref)) {
|
|
482
497
|
notes.push(`Release notes: https://github.com/${target.repository}/releases/tag/${target.ref}`);
|
|
483
498
|
}
|
|
@@ -619,6 +634,18 @@ export async function changeProject(args) {
|
|
|
619
634
|
progress.stop("Starter ready");
|
|
620
635
|
spinning = false;
|
|
621
636
|
const cli = await cliCommand(project);
|
|
637
|
+
// Every project records the Starter's modules when it has any, so none recorded against a
|
|
638
|
+
// Starter with modules means package.json lost them, and the project's files can't be told apart.
|
|
639
|
+
const starterModules = Object.keys(baseManifest.modules);
|
|
640
|
+
if (!Object.keys(project.modules).length && starterModules.length) {
|
|
641
|
+
throw new UsageError(`package.json does not record this project's modules, which Starter ${project.starterRef} has, so the CLI cannot tell which of their files the project has. ` +
|
|
642
|
+
`Add them to its "clubedge" field, as "modules": { "${starterModules[0]}": "<option>", ... } with one entry for each of ${starterModules.join(", ")}, then run this again.`);
|
|
643
|
+
}
|
|
644
|
+
// Starters before v0.5.0 have no modules or apps to change; their projects upgrade first.
|
|
645
|
+
if (!upgrading && !starterModules.length && !appSlots(baseManifest).length) {
|
|
646
|
+
throw new UsageError(`Starter ${project.starterRef}, which this project comes from, has no modules or apps to ${command.name}: they came in later releases. ` +
|
|
647
|
+
`Upgrade the project first with \`${cli} upgrade\`, then ${command.name} them.`);
|
|
648
|
+
}
|
|
622
649
|
const complete = command.name === "upgrade" ? command : await completeCommand(baseManifest, project, command, interactive);
|
|
623
650
|
if (!complete) {
|
|
624
651
|
outro("Cancelled; nothing was changed.");
|
|
@@ -651,9 +678,14 @@ export async function changeProject(args) {
|
|
|
651
678
|
// The files are written now, so what is left to resolve is reported even when the install fails.
|
|
652
679
|
let installError;
|
|
653
680
|
const manifests = await conflictedManifests(project.root);
|
|
681
|
+
// A patch pnpm would not use stops the install, so it is reported, and the install left until it is fixed.
|
|
682
|
+
const unused = manifests.length ? [] : await unusedPatches(project.root);
|
|
654
683
|
if (manifests.length) {
|
|
655
684
|
log.warn(conflictedManifestWarning(manifests, args.install));
|
|
656
685
|
}
|
|
686
|
+
else if (unused.length) {
|
|
687
|
+
log.warn(unusedPatchWarning(unused, args.install));
|
|
688
|
+
}
|
|
657
689
|
else if (args.install) {
|
|
658
690
|
const { pnpm, problem } = await detectPnpm();
|
|
659
691
|
if (pnpm) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clubedge/create-clubedge-app",
|
|
3
|
-
"version": "0.48.
|
|
3
|
+
"version": "0.48.1",
|
|
4
4
|
"description": "Create React apps from Clubedge Starter with a CLI or local MCP server.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "pnpm@10.9.0",
|
|
@@ -24,8 +24,8 @@
|
|
|
24
24
|
},
|
|
25
25
|
"clubedge": {
|
|
26
26
|
"starterRepository": "Clubedge/clubedge-starter",
|
|
27
|
-
"starterRef": "v0.31.
|
|
28
|
-
"starterCommit": "
|
|
27
|
+
"starterRef": "v0.31.2",
|
|
28
|
+
"starterCommit": "2635acabc29de2eb3656bc9e6741eac8bd72cd38"
|
|
29
29
|
},
|
|
30
30
|
"license": "Apache-2.0",
|
|
31
31
|
"repository": {
|
|
@@ -5,26 +5,31 @@ reference it, such as subscriptions, delete their rows with it."""
|
|
|
5
5
|
from clubedge_api.contract import User
|
|
6
6
|
from clubedge_api.db import connect
|
|
7
7
|
|
|
8
|
+
FIND_USER = "select id from users where auth_user_id = %s limit 1"
|
|
9
|
+
|
|
8
10
|
|
|
9
11
|
async def ensure_user_id(user: User) -> str:
|
|
10
12
|
"""The users row id for the signed-in user. Reads first, so requests that only need the id
|
|
11
13
|
don't write; creates the row when it is missing."""
|
|
12
14
|
async with connect() as db:
|
|
13
|
-
cursor = await db.execute(
|
|
14
|
-
"select id from users where auth_user_id = %s limit 1", (user["id"],)
|
|
15
|
-
)
|
|
15
|
+
cursor = await db.execute(FIND_USER, (user["id"],))
|
|
16
16
|
row = await cursor.fetchone()
|
|
17
17
|
if row is None:
|
|
18
|
+
# Without a conflict target, every unique index settles the race between a user's
|
|
19
|
+
# first requests, which can insert at once: one inserts the row, the others read it.
|
|
18
20
|
cursor = await db.execute(
|
|
19
21
|
"insert into users (auth_user_id, email) values (%s, %s)"
|
|
20
|
-
" on conflict
|
|
21
|
-
" set email = excluded.email, updated_at = now()"
|
|
22
|
-
" returning id",
|
|
22
|
+
" on conflict do nothing returning id",
|
|
23
23
|
(user["id"], user["email"]),
|
|
24
24
|
)
|
|
25
25
|
row = await cursor.fetchone()
|
|
26
|
+
if row is None:
|
|
27
|
+
cursor = await db.execute(FIND_USER, (user["id"],))
|
|
28
|
+
row = await cursor.fetchone()
|
|
26
29
|
if row is None:
|
|
27
|
-
raise RuntimeError(
|
|
30
|
+
raise RuntimeError(
|
|
31
|
+
"Another users row has this user's email address, so it can't have one of its own."
|
|
32
|
+
)
|
|
28
33
|
return str(row[0])
|
|
29
34
|
|
|
30
35
|
|
|
@@ -27,10 +27,28 @@ def test_creates_the_users_row_when_it_is_missing(monkeypatch: pytest.MonkeyPatc
|
|
|
27
27
|
assert asyncio.run(users.ensure_user_id(USER)) == "row-2"
|
|
28
28
|
[_, (insert, params)] = db.statements
|
|
29
29
|
assert insert.startswith("insert into users (auth_user_id, email) values (%s, %s)")
|
|
30
|
-
|
|
30
|
+
# No conflict target: every unique index, the email's too, settles a race.
|
|
31
|
+
assert insert.endswith(" on conflict do nothing returning id")
|
|
31
32
|
assert params == ("user-1", "ada@example.com")
|
|
32
33
|
|
|
33
34
|
|
|
35
|
+
def test_reads_the_row_another_first_request_created(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
36
|
+
# Two first requests at once: the insert does nothing, and the other request's row is read.
|
|
37
|
+
db = database(monkeypatch, None, None, ("row-3",))
|
|
38
|
+
assert asyncio.run(users.ensure_user_id(USER)) == "row-3"
|
|
39
|
+
assert [statement for statement, _ in db.statements] == [
|
|
40
|
+
users.FIND_USER,
|
|
41
|
+
db.statements[1][0],
|
|
42
|
+
users.FIND_USER,
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_says_so_when_another_users_row_has_the_email(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
47
|
+
database(monkeypatch, None, None, None)
|
|
48
|
+
with pytest.raises(RuntimeError, match="Another users row has this user's email address"):
|
|
49
|
+
asyncio.run(users.ensure_user_id(USER))
|
|
50
|
+
|
|
51
|
+
|
|
34
52
|
def test_reads_and_writes_the_display_name(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
35
53
|
db = database(monkeypatch, ("Ada",), None)
|
|
36
54
|
assert asyncio.run(users.get_display_name("row-1")) == "Ada"
|
package/template/package.json
CHANGED
|
@@ -4,7 +4,13 @@ import { migrate } from "drizzle-orm/pglite/migrator";
|
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { describe, expect, it } from "vitest";
|
|
6
6
|
import type { Database } from "./client";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
deleteUserProfile,
|
|
9
|
+
ensureUserId,
|
|
10
|
+
getDisplayName,
|
|
11
|
+
setDisplayName,
|
|
12
|
+
upsertUserProfile,
|
|
13
|
+
} from "./users";
|
|
8
14
|
|
|
9
15
|
/** A fresh PostgreSQL (PGlite) with the app's migrations applied. */
|
|
10
16
|
async function database() {
|
|
@@ -28,6 +34,53 @@ describe("user profiles", () => {
|
|
|
28
34
|
expect(await getDisplayName(db, id)).toBeNull();
|
|
29
35
|
}, 30_000);
|
|
30
36
|
|
|
37
|
+
it("gives a user's first requests at once the same row", async () => {
|
|
38
|
+
const db = await database();
|
|
39
|
+
const ids = await Promise.all([1, 2, 3, 4].map(() => ensureUserId(db, profile)));
|
|
40
|
+
expect(new Set(ids).size).toBe(1);
|
|
41
|
+
const synced = await Promise.all([1, 2].map(() => upsertUserProfile(db, profile)));
|
|
42
|
+
expect(synced).toEqual([ids[0], ids[0]]);
|
|
43
|
+
}, 30_000);
|
|
44
|
+
|
|
45
|
+
it("reads the row of a user's first request that inserted it at once with this one", async () => {
|
|
46
|
+
const db = await database();
|
|
47
|
+
const id = await ensureUserId(db, profile);
|
|
48
|
+
// PGlite runs one statement at a time, so two inserts never race here. This stands in for
|
|
49
|
+
// the one that lost: the other request's row is in, and the email's unique index stopped it.
|
|
50
|
+
const lost = Object.assign(new Error("duplicate key value violates unique constraint"), {
|
|
51
|
+
code: "23505",
|
|
52
|
+
});
|
|
53
|
+
const racing: Database = Object.create(db, {
|
|
54
|
+
insert: {
|
|
55
|
+
value: () => {
|
|
56
|
+
throw lost;
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
expect(await upsertUserProfile(racing, profile)).toBe(id);
|
|
61
|
+
await expect(
|
|
62
|
+
upsertUserProfile(racing, { ...profile, email: "grace@example.com" }),
|
|
63
|
+
).rejects.toBe(lost);
|
|
64
|
+
}, 30_000);
|
|
65
|
+
|
|
66
|
+
it("leaves an email address another user's row has to that row", async () => {
|
|
67
|
+
const db = await database();
|
|
68
|
+
const id = await ensureUserId(db, profile);
|
|
69
|
+
await ensureUserId(db, { authUserId: "another-user", email: "grace@example.com" });
|
|
70
|
+
await expect(
|
|
71
|
+
upsertUserProfile(db, { ...profile, email: "grace@example.com" }),
|
|
72
|
+
).rejects.toMatchObject({ cause: { code: "23505" } });
|
|
73
|
+
expect(await upsertUserProfile(db, profile)).toBe(id);
|
|
74
|
+
}, 30_000);
|
|
75
|
+
|
|
76
|
+
it("says so when another user's row has the email address, which is unique", async () => {
|
|
77
|
+
const db = await database();
|
|
78
|
+
await ensureUserId(db, profile);
|
|
79
|
+
await expect(ensureUserId(db, { ...profile, authUserId: "another-user" })).rejects.toThrow(
|
|
80
|
+
"Another users row has this user's email address, so it can't have one of its own.",
|
|
81
|
+
);
|
|
82
|
+
}, 30_000);
|
|
83
|
+
|
|
31
84
|
it("deletes a profile by its identity-provider user", async () => {
|
|
32
85
|
const db = await database();
|
|
33
86
|
const id = await ensureUserId(db, profile);
|
|
@@ -2,20 +2,61 @@ import { eq } from "drizzle-orm";
|
|
|
2
2
|
import type { Database } from "./client";
|
|
3
3
|
import { users } from "./schema";
|
|
4
4
|
|
|
5
|
+
/** The users row of an identity-provider user, if it has one. */
|
|
6
|
+
async function findUser(
|
|
7
|
+
db: Database,
|
|
8
|
+
authUserId: string,
|
|
9
|
+
): Promise<{ id: string; email: string } | undefined> {
|
|
10
|
+
const [row] = await db
|
|
11
|
+
.select({ id: users.id, email: users.email })
|
|
12
|
+
.from(users)
|
|
13
|
+
.where(eq(users.authUserId, authUserId))
|
|
14
|
+
.limit(1);
|
|
15
|
+
return row;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Whether an error is PostgreSQL's unique violation (23505), as the driver or Drizzle raises it. */
|
|
19
|
+
function isUniqueViolation(error: unknown): boolean {
|
|
20
|
+
const code = (value: unknown) =>
|
|
21
|
+
typeof value === "object" && value !== null && "code" in value ? value.code : undefined;
|
|
22
|
+
const cause =
|
|
23
|
+
typeof error === "object" && error !== null && "cause" in error ? error.cause : undefined;
|
|
24
|
+
return code(error) === "23505" || code(cause) === "23505";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The users row of another identity-provider user has this one's email address, which is
|
|
29
|
+
* unique, so this user can't have a row of its own.
|
|
30
|
+
*/
|
|
31
|
+
function emailTaken(): Error {
|
|
32
|
+
return new Error(
|
|
33
|
+
"Another users row has this user's email address, so it can't have one of its own.",
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
5
37
|
/** Creates or updates the app-owned profile row for an identity-provider user; returns its id. */
|
|
6
38
|
export async function upsertUserProfile(
|
|
7
39
|
db: Database,
|
|
8
40
|
profile: { authUserId: string; email: string },
|
|
9
41
|
): Promise<string> {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
42
|
+
try {
|
|
43
|
+
const [row] = await db
|
|
44
|
+
.insert(users)
|
|
45
|
+
.values(profile)
|
|
46
|
+
.onConflictDoUpdate({
|
|
47
|
+
target: users.authUserId,
|
|
48
|
+
set: { email: profile.email, updatedAt: new Date() },
|
|
49
|
+
})
|
|
50
|
+
.returning({ id: users.id });
|
|
51
|
+
return row!.id;
|
|
52
|
+
} catch (error) {
|
|
53
|
+
// A user's first requests can insert at once. The conflict target settles only auth_user_id,
|
|
54
|
+
// so the email's unique index stops all but one, whose row the others then read. A row with
|
|
55
|
+
// another address means the index stopped an update to one that another user's row has.
|
|
56
|
+
const row = isUniqueViolation(error) ? await findUser(db, profile.authUserId) : undefined;
|
|
57
|
+
if (row?.email === profile.email) return row.id;
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
19
60
|
}
|
|
20
61
|
|
|
21
62
|
/**
|
|
@@ -26,12 +67,18 @@ export async function ensureUserId(
|
|
|
26
67
|
db: Database,
|
|
27
68
|
profile: { authUserId: string; email: string },
|
|
28
69
|
): Promise<string> {
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
70
|
+
const existing = await findUser(db, profile.authUserId);
|
|
71
|
+
if (existing) return existing.id;
|
|
72
|
+
// Without a conflict target, every unique index settles the race between a user's first
|
|
73
|
+
// requests, which can insert at once: one inserts the row, and the others read it.
|
|
74
|
+
const [created] = await db
|
|
75
|
+
.insert(users)
|
|
76
|
+
.values(profile)
|
|
77
|
+
.onConflictDoNothing()
|
|
78
|
+
.returning({ id: users.id });
|
|
79
|
+
const id = created?.id ?? (await findUser(db, profile.authUserId))?.id;
|
|
80
|
+
if (!id) throw emailTaken();
|
|
81
|
+
return id;
|
|
35
82
|
}
|
|
36
83
|
|
|
37
84
|
/** The display name the user chose, or null. */
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clubedge/observability",
|
|
3
|
-
"version": "0.31.
|
|
3
|
+
"version": "0.31.2",
|
|
4
4
|
"description": "Observability interface: error reports and spans for @clubedge/core's telemetry hooks, and an in-memory provider for tests.",
|
|
5
5
|
"private": true,
|
|
6
6
|
"license": "Apache-2.0",
|
package/template.lock.json
CHANGED