@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.
Files changed (54) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +12 -10
  3. package/dist/core/package-json-merge.js +40 -0
  4. package/dist/core/patches.js +140 -0
  5. package/dist/core/project.js +3 -5
  6. package/dist/modify.js +33 -1
  7. package/package.json +3 -3
  8. package/template/apps/api/package.json +1 -1
  9. package/template/apps/api-fastapi/package.json +1 -1
  10. package/template/apps/api-fastapi/pyproject.toml +1 -1
  11. package/template/apps/api-fastapi/src/clubedge_api/users.py +12 -7
  12. package/template/apps/api-fastapi/tests/test_users.py +19 -1
  13. package/template/apps/api-fastapi/uv.lock +1 -1
  14. package/template/apps/capacitor/package.json +1 -1
  15. package/template/apps/expo/package.json +1 -1
  16. package/template/apps/start/package.json +1 -1
  17. package/template/apps/vite/package.json +1 -1
  18. package/template/apps/web/package.json +1 -1
  19. package/template/deploy/cloudflare/package.json +1 -1
  20. package/template/package.json +1 -1
  21. package/template/packages/auth/package.json +1 -1
  22. package/template/packages/auth-appwrite/package.json +1 -1
  23. package/template/packages/auth-better-auth/package.json +1 -1
  24. package/template/packages/auth-firebase/package.json +1 -1
  25. package/template/packages/auth-supabase/package.json +1 -1
  26. package/template/packages/cache/package.json +1 -1
  27. package/template/packages/cache-redis/package.json +1 -1
  28. package/template/packages/contract/package.json +1 -1
  29. package/template/packages/core/package.json +1 -1
  30. package/template/packages/db/package.json +1 -1
  31. package/template/packages/db/src/users.test.ts +54 -1
  32. package/template/packages/db/src/users.ts +62 -15
  33. package/template/packages/email/package.json +1 -1
  34. package/template/packages/email-resend/package.json +1 -1
  35. package/template/packages/email-smtp/package.json +1 -1
  36. package/template/packages/jobs/package.json +1 -1
  37. package/template/packages/jobs-bullmq/package.json +1 -1
  38. package/template/packages/jobs-inngest/package.json +1 -1
  39. package/template/packages/jobs-pg-boss/package.json +1 -1
  40. package/template/packages/local-db/package.json +1 -1
  41. package/template/packages/mobile/package.json +1 -1
  42. package/template/packages/observability/package.json +1 -1
  43. package/template/packages/observability-otel/package.json +1 -1
  44. package/template/packages/observability-sentry/package.json +1 -1
  45. package/template/packages/payments/package.json +1 -1
  46. package/template/packages/payments-stripe/package.json +1 -1
  47. package/template/packages/push/package.json +1 -1
  48. package/template/packages/push-expo/package.json +1 -1
  49. package/template/packages/push-native/package.json +1 -1
  50. package/template/packages/storage/package.json +1 -1
  51. package/template/packages/storage-s3/package.json +1 -1
  52. package/template/packages/storage-supabase/package.json +1 -1
  53. package/template/packages/ui/package.json +1 -1
  54. 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.1
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.1 # or a specific tag, branch, or commit
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.0
421
+ create-clubedge-app v0.48.1
420
422
  |
421
423
  v
422
- Clubedge Starter v0.31.1
423
- commit 2fa0adfd67ef3511f577d11fdca4109cc8554759
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.0` | `clubedge-starter@v0.31.1` | The CLI's required, tested default |
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.1` tag is expected to resolve to commit
435
- `2fa0adfd67ef3511f577d11fdca4109cc8554759`. The CLI verifies this before
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.1`. 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.
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.1`, and publish the Starter release.
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
+ }
@@ -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, but always their modules.
61
- if (!origin.modules) {
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
- const { modules, introduced, dropped } = upgradedModules(targetManifest, project, cli);
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.0",
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.1",
28
- "starterCommit": "2fa0adfd67ef3511f577d11fdca4109cc8554759"
27
+ "starterRef": "v0.31.2",
28
+ "starterCommit": "2635acabc29de2eb3656bc9e6741eac8bd72cd38"
29
29
  },
30
30
  "license": "Apache-2.0",
31
31
  "repository": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/api",
3
- "version": "0.31.1",
3
+ "version": "0.31.2",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/api-fastapi",
3
- "version": "0.31.1",
3
+ "version": "0.31.2",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "clubedge-api"
3
- version = "0.31.1"
3
+ version = "0.31.2"
4
4
  description = "The project's API, implementing packages/contract/openapi.yaml."
5
5
  requires-python = ">=3.12"
6
6
  dependencies = [
@@ -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 (auth_user_id) do update"
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("The users row was not created.")
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
- assert "on conflict (auth_user_id) do update" in insert
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"
@@ -389,7 +389,7 @@ wheels = [
389
389
 
390
390
  [[package]]
391
391
  name = "clubedge-api"
392
- version = "0.31.1"
392
+ version = "0.31.2"
393
393
  source = { editable = "." }
394
394
  default-groups = "all"
395
395
  dependencies = [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/capacitor",
3
- "version": "0.31.1",
3
+ "version": "0.31.2",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/expo",
3
- "version": "0.31.1",
3
+ "version": "0.31.2",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "main": "expo-router/entry",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/start",
3
- "version": "0.31.1",
3
+ "version": "0.31.2",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/vite",
3
- "version": "0.31.1",
3
+ "version": "0.31.2",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/web",
3
- "version": "0.31.1",
3
+ "version": "0.31.2",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "scripts": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/deploy-cloudflare",
3
- "version": "0.31.1",
3
+ "version": "0.31.2",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clubedge-starter-v1",
3
- "version": "0.31.1",
3
+ "version": "0.31.2",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "packageManager": "pnpm@10.9.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/auth",
3
- "version": "0.31.1",
3
+ "version": "0.31.2",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/auth-appwrite",
3
- "version": "0.31.1",
3
+ "version": "0.31.2",
4
4
  "description": "Appwrite provider for @clubedge/auth, run from the server with an API key and session cookies.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/auth-better-auth",
3
- "version": "0.31.1",
3
+ "version": "0.31.2",
4
4
  "description": "Better Auth adapter for @clubedge/auth, storing users and sessions in your own PostgreSQL.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/auth-firebase",
3
- "version": "0.31.1",
3
+ "version": "0.31.2",
4
4
  "description": "Firebase Authentication provider for @clubedge/auth, run from the server with session cookies.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/auth-supabase",
3
- "version": "0.31.1",
3
+ "version": "0.31.2",
4
4
  "description": "Supabase Auth adapter for @clubedge/auth.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/cache",
3
- "version": "0.31.1",
3
+ "version": "0.31.2",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/cache-redis",
3
- "version": "0.31.1",
3
+ "version": "0.31.2",
4
4
  "description": "Redis rate limiter and cache for @clubedge/cache.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/contract",
3
- "version": "0.31.1",
3
+ "version": "0.31.2",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/core",
3
- "version": "0.31.1",
3
+ "version": "0.31.2",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/db",
3
- "version": "0.31.1",
3
+ "version": "0.31.2",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -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 { deleteUserProfile, ensureUserId, getDisplayName, setDisplayName } from "./users";
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
- const [row] = await db
11
- .insert(users)
12
- .values(profile)
13
- .onConflictDoUpdate({
14
- target: users.authUserId,
15
- set: { email: profile.email, updatedAt: new Date() },
16
- })
17
- .returning({ id: users.id });
18
- return row!.id;
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 [row] = await db
30
- .select({ id: users.id })
31
- .from(users)
32
- .where(eq(users.authUserId, profile.authUserId))
33
- .limit(1);
34
- return row?.id ?? upsertUserProfile(db, profile);
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/email",
3
- "version": "0.31.1",
3
+ "version": "0.31.2",
4
4
  "description": "Email interface, console and in-memory providers, and message templates.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/email-resend",
3
- "version": "0.31.1",
3
+ "version": "0.31.2",
4
4
  "description": "Resend provider for @clubedge/email, through Resend's HTTP API.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/email-smtp",
3
- "version": "0.31.1",
3
+ "version": "0.31.2",
4
4
  "description": "SMTP provider for @clubedge/email, for any SMTP server.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/jobs",
3
- "version": "0.31.1",
3
+ "version": "0.31.2",
4
4
  "description": "Background jobs interface: job definitions, a queue contract, and an in-memory queue.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/jobs-bullmq",
3
- "version": "0.31.1",
3
+ "version": "0.31.2",
4
4
  "description": "BullMQ provider for @clubedge/jobs: a job queue stored in Redis.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/jobs-inngest",
3
- "version": "0.31.1",
3
+ "version": "0.31.2",
4
4
  "description": "Inngest provider for @clubedge/jobs: jobs run by Inngest, which calls the app for each one.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/jobs-pg-boss",
3
- "version": "0.31.1",
3
+ "version": "0.31.2",
4
4
  "description": "pg-boss provider for @clubedge/jobs: a job queue stored in the project's PostgreSQL.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/local-db",
3
- "version": "0.31.1",
3
+ "version": "0.31.2",
4
4
  "description": "PostgreSQL for development without Docker: PGlite served over the PostgreSQL protocol.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/mobile",
3
- "version": "0.31.1",
3
+ "version": "0.31.2",
4
4
  "description": "The mobile app's native helpers, through Capacitor, and its own screens: the update notice and the demo.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/observability",
3
- "version": "0.31.1",
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",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/observability-otel",
3
- "version": "0.31.1",
3
+ "version": "0.31.2",
4
4
  "description": "OpenTelemetry provider for @clubedge/observability: traces and error logs exported over OTLP to any collector.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/observability-sentry",
3
- "version": "0.31.1",
3
+ "version": "0.31.2",
4
4
  "description": "Sentry provider for @clubedge/observability: server and browser errors, traces, and source map uploads.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/payments",
3
- "version": "0.31.1",
3
+ "version": "0.31.2",
4
4
  "description": "Provider-neutral payments: the PaymentProvider interface, billing logic, and subscription tables.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/payments-stripe",
3
- "version": "0.31.1",
3
+ "version": "0.31.2",
4
4
  "description": "Stripe provider for @clubedge/payments: Checkout, the customer portal, and verified webhooks.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/push",
3
- "version": "0.31.1",
3
+ "version": "0.31.2",
4
4
  "description": "Provider-neutral push notifications: the PushProvider interface, the devices table, and sending to a user's devices.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/push-expo",
3
- "version": "0.31.1",
3
+ "version": "0.31.2",
4
4
  "description": "Expo's push service for @clubedge/push: notifications to the native app's Expo push tokens.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/push-native",
3
- "version": "0.31.1",
3
+ "version": "0.31.2",
4
4
  "description": "FCM and APNs for @clubedge/push: notifications sent straight to Android's and Apple's push services.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/storage",
3
- "version": "0.31.1",
3
+ "version": "0.31.2",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/storage-s3",
3
- "version": "0.31.1",
3
+ "version": "0.31.2",
4
4
  "description": "S3-compatible adapter for @clubedge/storage.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/storage-supabase",
3
- "version": "0.31.1",
3
+ "version": "0.31.2",
4
4
  "description": "Supabase Storage adapter for @clubedge/storage.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/ui",
3
- "version": "0.31.1",
3
+ "version": "0.31.2",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "repository": "Clubedge/clubedge-starter",
3
- "ref": "v0.31.1",
4
- "commit": "2fa0adfd67ef3511f577d11fdca4109cc8554759",
3
+ "ref": "v0.31.2",
4
+ "commit": "2635acabc29de2eb3656bc9e6741eac8bd72cd38",
5
5
  "files": [
6
6
  ".dockerignore",
7
7
  ".env.example",