alepha 0.20.2 → 0.20.3

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 (208) hide show
  1. package/README.md +0 -1
  2. package/assets/swagger-ui/swagger-ui-bundle.js +1 -1
  3. package/assets/swagger-ui/swagger-ui.css +1 -1
  4. package/dist/api/audits/index.browser.js +49 -0
  5. package/dist/api/audits/index.browser.js.map +1 -1
  6. package/dist/api/audits/index.d.ts.map +1 -1
  7. package/dist/api/audits/index.js +49 -0
  8. package/dist/api/audits/index.js.map +1 -1
  9. package/dist/api/files/index.d.ts.map +1 -1
  10. package/dist/api/files/index.js.map +1 -1
  11. package/dist/api/jobs/index.d.ts +16 -75
  12. package/dist/api/jobs/index.d.ts.map +1 -1
  13. package/dist/api/jobs/index.js.map +1 -1
  14. package/dist/api/keys/index.js.map +1 -1
  15. package/dist/api/notifications/index.d.ts +1 -10
  16. package/dist/api/notifications/index.d.ts.map +1 -1
  17. package/dist/api/organizations/index.d.ts.map +1 -1
  18. package/dist/api/parameters/index.browser.js +37 -0
  19. package/dist/api/parameters/index.browser.js.map +1 -1
  20. package/dist/api/parameters/index.d.ts +4 -65
  21. package/dist/api/parameters/index.d.ts.map +1 -1
  22. package/dist/api/parameters/index.js +37 -0
  23. package/dist/api/parameters/index.js.map +1 -1
  24. package/dist/api/payments/index.d.ts.map +1 -1
  25. package/dist/api/payments/index.js.map +1 -1
  26. package/dist/api/users/index.d.ts +207 -5184
  27. package/dist/api/users/index.d.ts.map +1 -1
  28. package/dist/api/users/index.js +2 -4
  29. package/dist/api/users/index.js.map +1 -1
  30. package/dist/api/verifications/index.d.ts.map +1 -1
  31. package/dist/api/verifications/index.js +2 -1
  32. package/dist/api/verifications/index.js.map +1 -1
  33. package/dist/bucket/index.js +5 -1
  34. package/dist/bucket/index.js.map +1 -1
  35. package/dist/bucket/index.workerd.js +5 -1
  36. package/dist/bucket/index.workerd.js.map +1 -1
  37. package/dist/cache/core/index.js.map +1 -1
  38. package/dist/cache/core/index.workerd.js.map +1 -1
  39. package/dist/captcha/index.js.map +1 -1
  40. package/dist/cli/core/index.d.ts +217 -11647
  41. package/dist/cli/core/index.d.ts.map +1 -1
  42. package/dist/cli/core/index.js +706 -42
  43. package/dist/cli/core/index.js.map +1 -1
  44. package/dist/cli/devtools/index.js +7 -1
  45. package/dist/cli/devtools/index.js.map +1 -1
  46. package/dist/cli/platform/index.d.ts +41 -64
  47. package/dist/cli/platform/index.d.ts.map +1 -1
  48. package/dist/cli/platform/index.js +47 -0
  49. package/dist/cli/platform/index.js.map +1 -1
  50. package/dist/cli/vendor/index.js +15 -0
  51. package/dist/cli/vendor/index.js.map +1 -1
  52. package/dist/command/index.js +1 -1
  53. package/dist/command/index.js.map +1 -1
  54. package/dist/core/index.browser.js.map +1 -1
  55. package/dist/core/index.d.ts +2 -8
  56. package/dist/core/index.d.ts.map +1 -1
  57. package/dist/core/index.js.map +1 -1
  58. package/dist/core/index.native.js.map +1 -1
  59. package/dist/core/index.workerd.js.map +1 -1
  60. package/dist/crypto/index.js.map +1 -1
  61. package/dist/datetime/index.js.map +1 -1
  62. package/dist/email/core/index.js.map +1 -1
  63. package/dist/email/smtp/index.js +2 -10522
  64. package/dist/email/smtp/index.js.map +1 -1
  65. package/dist/fake/index.d.ts +4 -8085
  66. package/dist/fake/index.d.ts.map +1 -1
  67. package/dist/fake/index.js +3 -33554
  68. package/dist/fake/index.js.map +1 -1
  69. package/dist/lock/core/index.js.map +1 -1
  70. package/dist/lock/redis/index.js.map +1 -1
  71. package/dist/logger/index.js +32 -1
  72. package/dist/logger/index.js.map +1 -1
  73. package/dist/mcp/index.js +5 -1
  74. package/dist/mcp/index.js.map +1 -1
  75. package/dist/orm/core/index.browser.js +1 -361
  76. package/dist/orm/core/index.browser.js.map +1 -1
  77. package/dist/orm/core/index.bun.js +14 -406
  78. package/dist/orm/core/index.bun.js.map +1 -1
  79. package/dist/orm/core/index.d.ts +96 -5117
  80. package/dist/orm/core/index.d.ts.map +1 -1
  81. package/dist/orm/core/index.js +23 -419
  82. package/dist/orm/core/index.js.map +1 -1
  83. package/dist/orm/postgres/index.bun.js +17 -20
  84. package/dist/orm/postgres/index.bun.js.map +1 -1
  85. package/dist/orm/postgres/index.d.ts +2 -613
  86. package/dist/orm/postgres/index.d.ts.map +1 -1
  87. package/dist/orm/postgres/index.js +17 -20
  88. package/dist/orm/postgres/index.js.map +1 -1
  89. package/dist/react/core/index.js.map +1 -1
  90. package/dist/react/i18n/index.js.map +1 -1
  91. package/dist/react/intro/index.js +22 -17
  92. package/dist/react/intro/index.js.map +1 -1
  93. package/dist/react/router/index.browser.js +78 -2
  94. package/dist/react/router/index.browser.js.map +1 -1
  95. package/dist/react/router/index.d.ts +22 -1
  96. package/dist/react/router/index.d.ts.map +1 -1
  97. package/dist/react/router/index.js +102 -4
  98. package/dist/react/router/index.js.map +1 -1
  99. package/dist/react/testing/index.d.ts +1 -411
  100. package/dist/react/testing/index.d.ts.map +1 -1
  101. package/dist/react/testing/index.js +13 -12293
  102. package/dist/react/testing/index.js.map +1 -1
  103. package/dist/react/ui/index.js +3 -0
  104. package/dist/react/ui/index.js.map +1 -1
  105. package/dist/react/websocket/index.js.map +1 -1
  106. package/dist/redis/index.js.map +1 -1
  107. package/dist/scheduler/index.d.ts +1 -83
  108. package/dist/scheduler/index.d.ts.map +1 -1
  109. package/dist/scheduler/index.js +2 -391
  110. package/dist/scheduler/index.js.map +1 -1
  111. package/dist/scheduler/index.workerd.js +2 -391
  112. package/dist/scheduler/index.workerd.js.map +1 -1
  113. package/dist/security/index.browser.js.map +1 -1
  114. package/dist/security/index.d.ts +2 -325
  115. package/dist/security/index.d.ts.map +1 -1
  116. package/dist/security/index.js +3 -1362
  117. package/dist/security/index.js.map +1 -1
  118. package/dist/server/auth/index.d.ts +1 -1054
  119. package/dist/server/auth/index.d.ts.map +1 -1
  120. package/dist/server/auth/index.js +16 -1224
  121. package/dist/server/auth/index.js.map +1 -1
  122. package/dist/server/cookies/index.js.map +1 -1
  123. package/dist/server/core/index.browser.js.map +1 -1
  124. package/dist/server/core/index.d.ts +1 -4
  125. package/dist/server/core/index.d.ts.map +1 -1
  126. package/dist/server/core/index.js +19 -4
  127. package/dist/server/core/index.js.map +1 -1
  128. package/dist/server/links/index.browser.js.map +1 -1
  129. package/dist/server/links/index.js.map +1 -1
  130. package/dist/server/metrics/index.d.ts +1 -514
  131. package/dist/server/metrics/index.d.ts.map +1 -1
  132. package/dist/server/metrics/index.js +4 -4356
  133. package/dist/server/metrics/index.js.map +1 -1
  134. package/dist/server/rate-limit/index.js.map +1 -1
  135. package/dist/server/static/index.js.map +1 -1
  136. package/dist/server/swagger/index.js +1 -1
  137. package/dist/server/swagger/index.js.map +1 -1
  138. package/dist/sms/index.js.map +1 -1
  139. package/dist/system/index.browser.js.map +1 -1
  140. package/dist/system/index.js.map +1 -1
  141. package/dist/system/index.workerd.js.map +1 -1
  142. package/dist/topic/core/index.js.map +1 -1
  143. package/dist/websocket/index.browser.js +21 -0
  144. package/dist/websocket/index.browser.js.map +1 -1
  145. package/dist/websocket/index.js +21 -0
  146. package/dist/websocket/index.js.map +1 -1
  147. package/package.json +18 -15
  148. package/src/api/files/__tests__/FileController.spec.ts +1 -1
  149. package/src/api/jobs/__tests__/$job.spec.ts +5 -1
  150. package/src/api/users/schemas/userQuerySchema.ts +0 -1
  151. package/src/api/users/services/UserService.ts +1 -5
  152. package/src/api/verifications/__tests__/CodeVerification.spec.ts +14 -0
  153. package/src/api/verifications/__tests__/LinkVerification.spec.ts +14 -0
  154. package/src/api/verifications/services/VerificationService.ts +1 -0
  155. package/src/cli/core/__tests__/init.spec.ts +208 -0
  156. package/src/cli/core/commands/init.ts +12 -0
  157. package/src/cli/core/services/PackageManagerUtils.ts +23 -6
  158. package/src/cli/core/services/ProjectScaffolder.ts +298 -20
  159. package/src/cli/core/tasks/BuildDockerTask.ts +9 -10
  160. package/src/cli/core/tasks/BuildServerTask.ts +8 -0
  161. package/src/cli/core/templates/apiIndexTs.ts +23 -1
  162. package/src/cli/core/templates/componentsJsonTs.ts +39 -0
  163. package/src/cli/core/templates/mainCss.ts +1 -0
  164. package/src/cli/core/templates/saasAdminLayoutTsx.ts +77 -0
  165. package/src/cli/core/templates/saasAdminPagesTsx.ts +26 -0
  166. package/src/cli/core/templates/saasAuthLayoutTsx.ts +20 -0
  167. package/src/cli/core/templates/saasAuthPagesTsx.ts +62 -0
  168. package/src/cli/core/templates/saasRealmProviderTs.ts +46 -0
  169. package/src/cli/core/templates/webAppRouterTs.ts +104 -1
  170. package/src/cli/core/templates/webIndexTs.ts +23 -1
  171. package/src/cli/platform/__tests__/SecretsCommand.spec.ts +2 -0
  172. package/src/command/providers/CliProvider.ts +1 -1
  173. package/src/core/interfaces/Service.ts +3 -1
  174. package/src/core/providers/TypeProvider.ts +1 -1
  175. package/src/logger/services/Logger.ts +1 -1
  176. package/src/mcp/__tests__/$resource.spec.ts +1 -1
  177. package/src/mcp/__tests__/$tool.spec.ts +1 -1
  178. package/src/mcp/__tests__/McpServerProvider.spec.ts +1 -1
  179. package/src/orm/__tests__/$repository-tests.ts +1 -0
  180. package/src/orm/__tests__/orm-next-tests.ts +2 -67
  181. package/src/orm/__tests__/orm-next.spec.ts +0 -21
  182. package/src/orm/core/index.shared.ts +0 -2
  183. package/src/orm/core/index.ts +1 -2
  184. package/src/orm/core/primitives/$repository.ts +3 -6
  185. package/src/orm/core/providers/drivers/DatabaseProvider.ts +0 -5
  186. package/src/orm/core/providers/drivers/NodeSqliteProvider.ts +11 -13
  187. package/src/orm/core/services/ModelBuilder.ts +1 -13
  188. package/src/orm/core/services/Repository.ts +1 -42
  189. package/src/orm/core/services/SqliteModelBuilder.ts +2 -33
  190. package/src/orm/postgres/services/PostgresModelBuilder.ts +10 -45
  191. package/src/react/intro/components/GettingStartedAuthSlide.tsx +11 -4
  192. package/src/react/router/__tests__/ReactBrowserProvider.browser.spec.ts +213 -2
  193. package/src/react/router/providers/ReactBrowserProvider.ts +73 -0
  194. package/src/react/router/providers/ReactBrowserRouterProvider.ts +1 -1
  195. package/src/react/router/providers/ReactPreloadProvider.ts +1 -1
  196. package/src/react/router/providers/ReactServerProvider.ts +1 -0
  197. package/src/scheduler/providers/CronProvider.ts +1 -1
  198. package/src/security/primitives/$basicAuth.ts +1 -1
  199. package/src/server/auth/providers/ServerAuthProvider.ts +5 -1
  200. package/src/server/core/interfaces/ServerRequest.ts +1 -0
  201. package/src/server/core/providers/ServerProvider.ts +1 -1
  202. package/src/server/core/providers/ServerRouterProvider.ts +2 -2
  203. package/src/server/core/services/HttpClient.ts +1 -1
  204. package/src/server/swagger/providers/ServerSwaggerProvider.ts +1 -1
  205. package/dist/react/testing/chunk-DBEY4PJZ.js +0 -16
  206. package/src/orm/core/__tests__/parseQueryString.spec.ts +0 -196
  207. package/src/orm/core/helpers/parseQueryString.ts +0 -502
  208. package/src/orm/core/primitives/$view.ts +0 -88
@@ -2,7 +2,6 @@ import { AlephaError } from "alepha";
2
2
  import type { SQL } from "drizzle-orm";
3
3
  import type { EntityPrimitive } from "../primitives/$entity.ts";
4
4
  import type { SequencePrimitive } from "../primitives/$sequence.ts";
5
- import type { ViewPrimitive } from "../primitives/$view.ts";
6
5
 
7
6
  /**
8
7
  * Database-specific table configuration functions
@@ -43,17 +42,6 @@ export abstract class ModelBuilder {
43
42
  },
44
43
  ): void;
45
44
 
46
- /**
47
- * Build a view from a view primitive.
48
- */
49
- abstract buildView(
50
- view: ViewPrimitive,
51
- options: {
52
- tables: Map<string, unknown>;
53
- schema: string;
54
- },
55
- ): void;
56
-
57
45
  /**
58
46
  * Build a sequence from a sequence primitive.
59
47
  */
@@ -192,7 +180,7 @@ export abstract class ModelBuilder {
192
180
  // Resolve foreign column references
193
181
  const foreignColumns = fkDef.foreignColumns.map((colRef) => {
194
182
  const entityCol = colRef();
195
- if (!entityCol || !entityCol.entity || !entityCol.name) {
183
+ if (!entityCol?.entity || !entityCol.name) {
196
184
  throw new AlephaError(
197
185
  `Invalid foreign column reference in ${entity.name}`,
198
186
  );
@@ -114,11 +114,7 @@ export abstract class Repository<T extends TObject> {
114
114
  constructor(entity: EntityPrimitive<T>, provider = DatabaseProvider) {
115
115
  this.entity = entity;
116
116
  this.provider = this.alepha.inject(provider);
117
- if ((entity as any).isView) {
118
- this.provider.registerView(entity as any);
119
- } else {
120
- this.provider.registerEntity(entity as EntityPrimitive);
121
- }
117
+ this.provider.registerEntity(entity as EntityPrimitive);
122
118
  }
123
119
 
124
120
  /**
@@ -150,13 +146,6 @@ export abstract class Repository<T extends TObject> {
150
146
  return this.entity.name;
151
147
  }
152
148
 
153
- /**
154
- * Whether this repository is backed by a view (read-only).
155
- */
156
- public get isReadOnly(): boolean {
157
- return (this.entity as any).isView === true;
158
- }
159
-
160
149
  /**
161
150
  * Getter for the database connection from the database provider.
162
151
  *
@@ -663,7 +652,6 @@ export abstract class Repository<T extends TObject> {
663
652
  data: Static<TObjectInsert<T>>,
664
653
  opts: StatementOptions = {},
665
654
  ): Promise<Static<T>> {
666
- this.assertWritable();
667
655
  this.stampOrganization(data);
668
656
  await this.alepha.events.emit("repository:create:before", {
669
657
  tableName: this.tableName,
@@ -705,7 +693,6 @@ export abstract class Repository<T extends TObject> {
705
693
  values: Array<Static<TObjectInsert<T>>>,
706
694
  opts: StatementOptions & { batchSize?: number } = {},
707
695
  ): Promise<Static<T>[]> {
708
- this.assertWritable();
709
696
  if (values.length === 0) {
710
697
  return [];
711
698
  }
@@ -784,7 +771,6 @@ export abstract class Repository<T extends TObject> {
784
771
  set?: WithSQL<Static<TObjectUpdate<T>>>;
785
772
  } = {},
786
773
  ): Promise<Static<T>> {
787
- this.assertWritable();
788
774
  this.stampOrganization(data);
789
775
  await this.alepha.events.emit("repository:create:before", {
790
776
  tableName: this.tableName,
@@ -857,7 +843,6 @@ export abstract class Repository<T extends TObject> {
857
843
  data: WithSQL<Static<TObjectUpdate<T>>>,
858
844
  opts: StatementOptions = {},
859
845
  ): Promise<Static<T>> {
860
- this.assertWritable();
861
846
  await this.alepha.events.emit("repository:update:before", {
862
847
  tableName: this.tableName,
863
848
  where,
@@ -938,7 +923,6 @@ export abstract class Repository<T extends TObject> {
938
923
  entity: Static<T>,
939
924
  opts: StatementOptions = {},
940
925
  ): Promise<void> {
941
- this.assertWritable();
942
926
  const row = entity as any;
943
927
 
944
928
  const id = row[this.id.key];
@@ -1024,7 +1008,6 @@ export abstract class Repository<T extends TObject> {
1024
1008
  data: WithSQL<Static<TObjectUpdate<T>>>,
1025
1009
  opts: StatementOptions = {},
1026
1010
  ): Promise<Array<number | string>> {
1027
- this.assertWritable();
1028
1011
  await this.alepha.events.emit("repository:update:before", {
1029
1012
  tableName: this.tableName,
1030
1013
  where,
@@ -1076,7 +1059,6 @@ export abstract class Repository<T extends TObject> {
1076
1059
  where: PgQueryWhereOrSQL<T> = {},
1077
1060
  opts: StatementOptions = {},
1078
1061
  ): Promise<Array<number | string>> {
1079
- this.assertWritable();
1080
1062
  const deletedAt = this.deletedAt();
1081
1063
  if (deletedAt && !opts.force) {
1082
1064
  return await this.updateMany(
@@ -1639,29 +1621,6 @@ export abstract class Repository<T extends TObject> {
1639
1621
  return entity as Static<T>;
1640
1622
  }
1641
1623
 
1642
- /**
1643
- * Throw if this repository is read-only (backed by a view).
1644
- */
1645
- protected assertWritable(): void {
1646
- if (this.isReadOnly) {
1647
- throw new AlephaError(
1648
- `Cannot write to view '${this.tableName}'. Views are read-only.`,
1649
- );
1650
- }
1651
- }
1652
-
1653
- /**
1654
- * Refresh a materialized view. PostgreSQL only.
1655
- */
1656
- public async refresh(): Promise<void> {
1657
- if (!(this.entity as any).materialized) {
1658
- throw new AlephaError(
1659
- `Cannot refresh '${this.tableName}'. Only materialized views support refresh.`,
1660
- );
1661
- }
1662
- await this.provider.execute(`REFRESH MATERIALIZED VIEW ${this.tableName}`);
1663
- }
1664
-
1665
1624
  /**
1666
1625
  * Build a cache key from method name and query parameters.
1667
1626
  */
@@ -16,7 +16,6 @@ import {
16
16
  type SQLiteColumnBuilderBase,
17
17
  type SQLiteTableWithColumns,
18
18
  sqliteTable,
19
- sqliteView,
20
19
  unique,
21
20
  uniqueIndex,
22
21
  } from "drizzle-orm/sqlite-core";
@@ -33,7 +32,6 @@ import {
33
32
  } from "../constants/PG_SYMBOLS.ts";
34
33
  import type { EntityPrimitive } from "../primitives/$entity.ts";
35
34
  import type { SequencePrimitive } from "../primitives/$sequence.ts";
36
- import type { ViewPrimitive } from "../primitives/$view.ts";
37
35
  import { ModelBuilder } from "./ModelBuilder.ts";
38
36
 
39
37
  export class SqliteModelBuilder extends ModelBuilder {
@@ -66,33 +64,6 @@ export class SqliteModelBuilder extends ModelBuilder {
66
64
  options.tables.set(tableName, table);
67
65
  }
68
66
 
69
- public buildView(
70
- view: ViewPrimitive,
71
- options: {
72
- tables: Map<string, unknown>;
73
- schema: string;
74
- },
75
- ) {
76
- const viewName = view.name;
77
- if (options.tables.has(viewName)) {
78
- return;
79
- }
80
-
81
- if (view.materialized) {
82
- throw new AlephaError("SQLite does not support materialized views");
83
- }
84
-
85
- const columns = this.schemaToSqliteColumns(
86
- viewName,
87
- view.schema,
88
- new Map(),
89
- options.tables,
90
- );
91
-
92
- const drizzleView = sqliteView(viewName, columns).existing();
93
- options.tables.set(viewName, drizzleView);
94
- }
95
-
96
67
  public buildSequence(
97
68
  sequence: SequencePrimitive,
98
69
  options: {
@@ -193,10 +164,8 @@ export class SqliteModelBuilder extends ModelBuilder {
193
164
  col = col.notNull();
194
165
  }
195
166
 
196
- return {
197
- ...columns,
198
- [key]: col,
199
- };
167
+ (columns as Record<string, unknown>)[key] = col;
168
+ return columns;
200
169
  }, {}) as SchemaToSqliteBuilder<T>;
201
170
  };
202
171
 
@@ -16,7 +16,6 @@ import {
16
16
  type SequencePrimitive,
17
17
  schema,
18
18
  sql,
19
- type ViewPrimitive,
20
19
  } from "alepha/orm";
21
20
  import type { BuildExtraConfigColumns } from "drizzle-orm";
22
21
  import * as pg from "drizzle-orm/pg-core";
@@ -29,11 +28,9 @@ import {
29
28
  type PgTableExtraConfigValue,
30
29
  type PgTableWithColumns,
31
30
  pgEnum,
32
- pgMaterializedView,
33
31
  pgSchema,
34
32
  pgSequence,
35
33
  pgTable,
36
- pgView,
37
34
  unique,
38
35
  uniqueIndex,
39
36
  } from "drizzle-orm/pg-core";
@@ -108,36 +105,6 @@ export class PostgresModelBuilder extends ModelBuilder {
108
105
  options.tables.set(tableName, table);
109
106
  }
110
107
 
111
- public buildView(
112
- view: ViewPrimitive,
113
- options: {
114
- tables: Map<string, unknown>;
115
- schema: string;
116
- },
117
- ) {
118
- const viewName = view.name;
119
- if (options.tables.has(viewName)) {
120
- return;
121
- }
122
-
123
- const columns = this.schemaToPgColumns(
124
- viewName,
125
- view.schema,
126
- { enum: pgEnum, table: pgTable, sequence: pgSequence } as any,
127
- new Map(),
128
- options.tables,
129
- );
130
-
131
- let drizzleView: unknown;
132
- if (view.materialized) {
133
- drizzleView = pgMaterializedView(viewName, columns).existing();
134
- } else {
135
- drizzleView = pgView(viewName, columns).existing();
136
- }
137
-
138
- options.tables.set(viewName, drizzleView);
139
- }
140
-
141
108
  public buildSequence(
142
109
  sequence: SequencePrimitive,
143
110
  options: {
@@ -244,10 +211,8 @@ export class PostgresModelBuilder extends ModelBuilder {
244
211
  col = col.notNull();
245
212
  }
246
213
 
247
- return {
248
- ...columns,
249
- [key]: col,
250
- };
214
+ (columns as Record<string, unknown>)[key] = col;
215
+ return columns;
251
216
  },
252
217
  {},
253
218
  ) as FromSchema<T>;
@@ -325,7 +290,14 @@ export class PostgresModelBuilder extends ModelBuilder {
325
290
  return pg.numeric(key);
326
291
  }
327
292
 
328
- if (t.schema.isString(value)) {
293
+ const isTypeEnum = (value: any): value is { enum: any[] } =>
294
+ t.schema.isUnsafe(value) &&
295
+ "type" in value &&
296
+ value.type === "string" &&
297
+ "enum" in value &&
298
+ Array.isArray(value.enum);
299
+
300
+ if (t.schema.isString(value) && !isTypeEnum(value)) {
329
301
  return this.mapStringToColumn(key, value);
330
302
  }
331
303
 
@@ -341,13 +313,6 @@ export class PostgresModelBuilder extends ModelBuilder {
341
313
  return schema(key, value);
342
314
  }
343
315
 
344
- const isTypeEnum = (value: any): value is { enum: any[] } =>
345
- t.schema.isUnsafe(value) &&
346
- "type" in value &&
347
- value.type === "string" &&
348
- "enum" in value &&
349
- Array.isArray(value.enum);
350
-
351
316
  if (t.schema.isArray(value)) {
352
317
  if (t.schema.isObject(value.items)) {
353
318
  return schema(key, value);
@@ -8,7 +8,7 @@ import type { GettingStartedSlide } from "./GettingStarted.tsx";
8
8
  * Returns undefined if auth routes are not configured.
9
9
  */
10
10
  export const useAuthSlide = (): GettingStartedSlide | undefined => {
11
- const { user } = useAuth();
11
+ const { user, logout } = useAuth();
12
12
  const router = useRouter();
13
13
 
14
14
  // Check if auth routes exist
@@ -19,8 +19,6 @@ export const useAuthSlide = (): GettingStartedSlide | undefined => {
19
19
 
20
20
  // User is logged in - show user info and logout option
21
21
  if (user) {
22
- const logoutAnchorProps = router.anchor(router.path("logout"));
23
-
24
22
  return {
25
23
  text: "Welcome back!",
26
24
  sub: `You're signed in as ${user.email || user.username || "user"}.`,
@@ -33,7 +31,16 @@ export const useAuthSlide = (): GettingStartedSlide | undefined => {
33
31
  num: "→",
34
32
  text: (
35
33
  <>
36
- <a {...logoutAnchorProps}>Sign out</a> to test the login flow
34
+ <a
35
+ href="#"
36
+ onClick={(e) => {
37
+ e.preventDefault();
38
+ logout();
39
+ }}
40
+ >
41
+ Sign out
42
+ </a>{" "}
43
+ to test the login flow
37
44
  </>
38
45
  ),
39
46
  },
@@ -1,9 +1,22 @@
1
1
  import { Alepha } from "alepha";
2
- import { beforeEach, describe, expect, it, vi } from "vitest";
3
- import { ReactBrowserProvider } from "../providers/ReactBrowserProvider.ts";
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import {
4
+ ReactBrowserProvider,
5
+ type RouterPushOptions,
6
+ reactBrowserOptions,
7
+ } from "../providers/ReactBrowserProvider.ts";
4
8
 
5
9
  class TestReactBrowserProvider extends ReactBrowserProvider {
6
10
  public testGetHydrationState = this.getHydrationState.bind(this);
11
+ public testAttachAnchorInterceptor = this.attachAnchorInterceptor.bind(this);
12
+ public pushCalls: Array<{ url: string; options?: RouterPushOptions }> = [];
13
+
14
+ public override async push(
15
+ url: string,
16
+ options?: RouterPushOptions,
17
+ ): Promise<void> {
18
+ this.pushCalls.push({ url, options });
19
+ }
7
20
  }
8
21
 
9
22
  describe("ReactBrowserProvider", () => {
@@ -102,4 +115,202 @@ describe("ReactBrowserProvider", () => {
102
115
  expect(result?.["alepha.i18n.locale"]).toBe("en");
103
116
  });
104
117
  });
118
+
119
+ describe("attachAnchorInterceptor", () => {
120
+ let alepha: Alepha;
121
+ let provider: TestReactBrowserProvider;
122
+ let detach: () => void;
123
+ let container: HTMLDivElement;
124
+
125
+ const createAnchor = (
126
+ attrs: Record<string, string>,
127
+ inner?: HTMLElement,
128
+ ): HTMLAnchorElement => {
129
+ const a = document.createElement("a");
130
+ for (const [k, v] of Object.entries(attrs)) {
131
+ a.setAttribute(k, v);
132
+ }
133
+ if (inner) {
134
+ a.appendChild(inner);
135
+ } else {
136
+ a.textContent = "link";
137
+ }
138
+ container.appendChild(a);
139
+ return a;
140
+ };
141
+
142
+ const click = (
143
+ target: HTMLElement,
144
+ init: MouseEventInit = {},
145
+ ): MouseEvent => {
146
+ const ev = new MouseEvent("click", {
147
+ bubbles: true,
148
+ cancelable: true,
149
+ button: 0,
150
+ ...init,
151
+ });
152
+ target.dispatchEvent(ev);
153
+ return ev;
154
+ };
155
+
156
+ beforeEach(() => {
157
+ alepha = Alepha.create();
158
+ provider = alepha.inject(TestReactBrowserProvider);
159
+ container = document.createElement("div");
160
+ document.body.appendChild(container);
161
+ detach = provider.testAttachAnchorInterceptor();
162
+ });
163
+
164
+ afterEach(() => {
165
+ detach();
166
+ container.remove();
167
+ });
168
+
169
+ it("intercepts plain internal /foo anchor clicks", () => {
170
+ const a = createAnchor({ href: "/foo" });
171
+
172
+ const ev = click(a);
173
+
174
+ expect(provider.pushCalls).toHaveLength(1);
175
+ expect(provider.pushCalls[0].url).toBe("/foo");
176
+ expect(ev.defaultPrevented).toBe(true);
177
+ });
178
+
179
+ it("preserves query and hash when intercepting", () => {
180
+ const a = createAnchor({ href: "/foo?x=1#bar" });
181
+
182
+ click(a);
183
+
184
+ expect(provider.pushCalls[0].url).toBe("/foo?x=1#bar");
185
+ });
186
+
187
+ it("ignores cmd-click (metaKey)", () => {
188
+ const a = createAnchor({ href: "/foo" });
189
+
190
+ const ev = click(a, { metaKey: true });
191
+
192
+ expect(provider.pushCalls).toHaveLength(0);
193
+ expect(ev.defaultPrevented).toBe(false);
194
+ });
195
+
196
+ it("ignores ctrl-click", () => {
197
+ const a = createAnchor({ href: "/foo" });
198
+
199
+ click(a, { ctrlKey: true });
200
+
201
+ expect(provider.pushCalls).toHaveLength(0);
202
+ });
203
+
204
+ it("ignores shift-click", () => {
205
+ const a = createAnchor({ href: "/foo" });
206
+
207
+ click(a, { shiftKey: true });
208
+
209
+ expect(provider.pushCalls).toHaveLength(0);
210
+ });
211
+
212
+ it("ignores alt-click", () => {
213
+ const a = createAnchor({ href: "/foo" });
214
+
215
+ click(a, { altKey: true });
216
+
217
+ expect(provider.pushCalls).toHaveLength(0);
218
+ });
219
+
220
+ it("ignores non-primary mouse buttons", () => {
221
+ const a = createAnchor({ href: "/foo" });
222
+
223
+ click(a, { button: 1 });
224
+
225
+ expect(provider.pushCalls).toHaveLength(0);
226
+ });
227
+
228
+ it("ignores anchors with target='_blank'", () => {
229
+ const a = createAnchor({ href: "/foo", target: "_blank" });
230
+
231
+ click(a);
232
+
233
+ expect(provider.pushCalls).toHaveLength(0);
234
+ });
235
+
236
+ it("ignores anchors with download attribute", () => {
237
+ const a = createAnchor({ href: "/foo", download: "" });
238
+
239
+ click(a);
240
+
241
+ expect(provider.pushCalls).toHaveLength(0);
242
+ });
243
+
244
+ it("ignores anchors with data-no-router attribute", () => {
245
+ const a = createAnchor({ href: "/foo", "data-no-router": "" });
246
+
247
+ click(a);
248
+
249
+ expect(provider.pushCalls).toHaveLength(0);
250
+ });
251
+
252
+ it("ignores mailto: hrefs", () => {
253
+ const a = createAnchor({ href: "mailto:foo@bar.com" });
254
+
255
+ click(a);
256
+
257
+ expect(provider.pushCalls).toHaveLength(0);
258
+ });
259
+
260
+ it("ignores tel: hrefs", () => {
261
+ const a = createAnchor({ href: "tel:+15555555" });
262
+
263
+ click(a);
264
+
265
+ expect(provider.pushCalls).toHaveLength(0);
266
+ });
267
+
268
+ it("ignores hrefs to external origins", () => {
269
+ const a = createAnchor({ href: "https://example.com/foo" });
270
+
271
+ click(a);
272
+
273
+ expect(provider.pushCalls).toHaveLength(0);
274
+ });
275
+
276
+ it("ignores hash-only #section hrefs", () => {
277
+ const a = createAnchor({ href: "#section" });
278
+
279
+ click(a);
280
+
281
+ expect(provider.pushCalls).toHaveLength(0);
282
+ });
283
+
284
+ it("intercepts when click target is nested inside the anchor", () => {
285
+ const span = document.createElement("span");
286
+ span.textContent = "inner";
287
+ createAnchor({ href: "/foo" }, span);
288
+
289
+ click(span);
290
+
291
+ expect(provider.pushCalls).toHaveLength(1);
292
+ expect(provider.pushCalls[0].url).toBe("/foo");
293
+ });
294
+
295
+ it("skips when defaultPrevented is already true", () => {
296
+ const a = createAnchor({ href: "/foo" });
297
+ a.addEventListener("click", (ev) => ev.preventDefault());
298
+
299
+ click(a);
300
+
301
+ expect(provider.pushCalls).toHaveLength(0);
302
+ });
303
+
304
+ it("respects interceptAnchorClicks=false at runtime", () => {
305
+ alepha.store.set(reactBrowserOptions.key, {
306
+ ...alepha.store.get(reactBrowserOptions.key)!,
307
+ interceptAnchorClicks: false,
308
+ });
309
+ const a = createAnchor({ href: "/foo" });
310
+
311
+ click(a);
312
+
313
+ expect(provider.pushCalls).toHaveLength(0);
314
+ });
315
+ });
105
316
  });
@@ -28,9 +28,20 @@ export const reactBrowserOptions = $atom({
28
28
  name: "alepha.react.browser.options",
29
29
  schema: t.object({
30
30
  scrollRestoration: t.enum(["top", "manual"]), // TODO: must be per page?
31
+ /**
32
+ * Intercept clicks on plain `<a href="/...">` anchors and route them
33
+ * through the SPA router, so authors don't need `<Link>` everywhere
34
+ * (notably for SSR/Markdown HTML rendered as raw markup).
35
+ *
36
+ * Skips: modifier keys, non-primary mouse buttons, `target` other than
37
+ * `_self`, `download`, `data-no-router`, non-http(s) schemes, hash-only
38
+ * hrefs, external origins, and clicks already `defaultPrevented`.
39
+ */
40
+ interceptAnchorClicks: t.boolean({ default: true }),
31
41
  }),
32
42
  default: {
33
43
  scrollRestoration: "top" as const,
44
+ interceptAnchorClicks: true,
34
45
  },
35
46
  });
36
47
 
@@ -325,8 +336,70 @@ export class ReactBrowserProvider {
325
336
 
326
337
  this.render();
327
338
  });
339
+
340
+ this.attachAnchorInterceptor();
328
341
  },
329
342
  });
343
+
344
+ /**
345
+ * Attach a delegated click listener that routes plain `<a href="/...">`
346
+ * clicks through the SPA router. Returns a detach function (used in tests).
347
+ *
348
+ * Bails out on modifier keys, non-primary mouse buttons, `target`, `download`,
349
+ * `data-no-router`, hash-only/external/non-http hrefs, and already-prevented
350
+ * events. Honors the runtime `interceptAnchorClicks` flag.
351
+ */
352
+ protected attachAnchorInterceptor(): () => void {
353
+ const onClick = (ev: MouseEvent) => {
354
+ if (!this.options.interceptAnchorClicks) return;
355
+ if (ev.defaultPrevented) return;
356
+ if (ev.button !== 0) return;
357
+ if (ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return;
358
+
359
+ const node = ev.target as Element | null;
360
+ const a = node?.closest?.("a");
361
+ if (!a) return;
362
+
363
+ if (a.hasAttribute("download")) return;
364
+ if (a.hasAttribute("data-no-router")) return;
365
+
366
+ const target = a.getAttribute("target");
367
+ if (target && target !== "_self") return;
368
+
369
+ const href = a.getAttribute("href");
370
+ if (!href) return;
371
+ if (href.startsWith("#")) return;
372
+ if (/^[a-z][a-z0-9+.-]*:/i.test(href)) {
373
+ // absolute scheme: only intercept if it points at our own origin
374
+ let url: URL;
375
+ try {
376
+ url = new URL(href);
377
+ } catch {
378
+ return;
379
+ }
380
+ if (url.origin !== this.location.origin) return;
381
+ ev.preventDefault();
382
+ const path = url.pathname + url.search + url.hash;
383
+ this.push(this.stripBase(path)).catch((e) => this.log.error(e));
384
+ return;
385
+ }
386
+
387
+ ev.preventDefault();
388
+ const url = new URL(href, this.location.href);
389
+ const path = url.pathname + url.search + url.hash;
390
+ this.push(this.stripBase(path)).catch((e) => this.log.error(e));
391
+ };
392
+
393
+ this.document.addEventListener("click", onClick);
394
+ return () => this.document.removeEventListener("click", onClick);
395
+ }
396
+
397
+ protected stripBase(path: string): string {
398
+ if (this.base && path.startsWith(this.base)) {
399
+ return path.slice(this.base.length) || "/";
400
+ }
401
+ return path;
402
+ }
330
403
  }
331
404
 
332
405
  // ---------------------------------------------------------------------------------------------------------------------
@@ -52,7 +52,7 @@ export class ReactBrowserRouterProvider extends RouterProvider<BrowserRoute> {
52
52
  previous: PreviousLayerData[] = [],
53
53
  meta = {},
54
54
  isStale: () => boolean = () => false,
55
- ): Promise<string | void> {
55
+ ): Promise<string | undefined> {
56
56
  const { pathname, search } = url;
57
57
 
58
58
  const entry: Partial<ReactRouterState> = {
@@ -65,7 +65,7 @@ export class ReactPreloadProvider {
65
65
  handler: ({ response }) => {
66
66
  // Only add to HTML responses (SSR pages)
67
67
  const contentType = response.headers["content-type"];
68
- if (!contentType || !contentType.includes("text/html")) {
68
+ if (!contentType?.includes("text/html")) {
69
69
  return;
70
70
  }
71
71
 
@@ -306,6 +306,7 @@ export class ReactServerProvider {
306
306
  // Skip SSR for file-like URLs hitting the catch-all wildcard.
307
307
  // Bots and crawlers often probe paths like /hello.txt, /wp-login.php, etc.
308
308
  // Rendering a full React page for these is wasteful — return a plain 404 instead.
309
+ // biome-ignore lint/complexity/useOptionalChain: staticFilePattern is `false | RegExp`; optional chaining doesn't narrow `false`
309
310
  if (staticFilePattern && staticFilePattern.test(url.pathname)) {
310
311
  reply.status = 404;
311
312
  reply.headers["content-type"] = "text/plain";
@@ -67,7 +67,7 @@ export class CronProvider {
67
67
  ? this.cronJobs.find((c) => c.name === name)
68
68
  : name;
69
69
 
70
- if (!cron || !cron.running) {
70
+ if (!cron?.running) {
71
71
  return;
72
72
  }
73
73