create-fullstack-scaffold 0.4.25 → 0.5.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 (137) hide show
  1. package/dist/cli/index.js +432 -15
  2. package/dist/cli/index.js.map +1 -1
  3. package/package.json +10 -13
  4. package/template/drizzle.config.ts +10 -4
  5. package/template/eslint-rules/__tests__/no-merged-api-type-export.test.ts +96 -0
  6. package/template/eslint-rules/no-cross-module-service-import.js +4 -0
  7. package/template/eslint-rules/no-merged-api-type-export.js +190 -0
  8. package/template/eslint.config.js +3 -0
  9. package/template/package.json +9 -6
  10. package/template/scripts/sync-agent-hooks.mjs +189 -0
  11. package/template/src/admin/components/__tests__/StatsCard.test.tsx +2 -2
  12. package/template/src/admin/pages/ContentPage.tsx +1 -1
  13. package/template/src/admin/pages/DisputesPage.tsx +1 -1
  14. package/template/src/admin/pages/OrdersPage.tsx +1 -1
  15. package/template/src/admin/pages/TicketsPage.tsx +1 -1
  16. package/template/src/admin/pages/__tests__/ContentPage.test.tsx +5 -1
  17. package/template/src/admin/pages/__tests__/DashboardPage.test.tsx +62 -8
  18. package/template/src/admin/pages/__tests__/DisputesPage.test.tsx +8 -1
  19. package/template/src/admin/pages/__tests__/OrdersPage.test.tsx +4 -2
  20. package/template/src/admin/pages/__tests__/RegisterPage.test.tsx +40 -43
  21. package/template/src/admin/pages/__tests__/SettingsPage.test.tsx +83 -21
  22. package/template/src/admin/pages/__tests__/TicketsPage.test.tsx +3 -1
  23. package/template/src/admin/services/apiClient.ts +2 -3
  24. package/template/src/cli/modules/content/index.ts +3 -3
  25. package/template/src/cli/modules/dispute/index.ts +3 -3
  26. package/template/src/cli/modules/ticket/index.ts +3 -3
  27. package/template/src/cli/modules/todo/index.ts +1 -1
  28. package/template/src/cli/rpc/client.ts +3 -4
  29. package/template/src/cli/rpc/index.ts +1 -1
  30. package/template/src/client/App.tsx +3 -39
  31. package/template/src/client/AppRoutes.tsx +53 -0
  32. package/template/src/client/entry-server.tsx +75 -0
  33. package/template/src/client/main.tsx +3 -1
  34. package/template/src/client/pages/SearchPage.tsx +1 -1
  35. package/template/src/client/services/apiClient.ts +12 -4
  36. package/template/src/client/stores/__tests__/todoStore.test.ts +12 -3
  37. package/template/src/client/stores/entry-stores.ts +36 -0
  38. package/template/src/client/stores/notificationStore.ts +4 -4
  39. package/template/src/client/stores/todoStore.ts +2 -2
  40. package/template/src/merchant/pages/DisputesPage.tsx +3 -3
  41. package/template/src/merchant/pages/OrdersPage.tsx +1 -1
  42. package/template/src/merchant/pages/ProductsPage.tsx +1 -1
  43. package/template/src/merchant/pages/SettingsPage.tsx +1 -1
  44. package/template/src/server/__tests__/integration/isr-full-flow.test.ts +251 -0
  45. package/template/src/server/__tests__/integration/todos-api.test.ts +7 -4
  46. package/template/src/server/app.ts +6 -4
  47. package/template/src/server/core/__tests__/isr-cache-cf.test.ts +96 -0
  48. package/template/src/server/core/__tests__/isr-cache.test.ts +96 -92
  49. package/template/src/server/core/__tests__/isr-invalidation.test.ts +29 -4
  50. package/template/src/server/core/__tests__/isr-registry.test.ts +215 -0
  51. package/template/src/server/core/__tests__/isr-renderer.test.ts +121 -0
  52. package/template/src/server/core/isr-cache.ts +61 -18
  53. package/template/src/server/core/isr-invalidation.ts +6 -12
  54. package/template/src/server/core/isr-registry.ts +104 -0
  55. package/template/src/server/core/isr-renderer.ts +75 -0
  56. package/template/src/server/db/schema/contents.ts +28 -20
  57. package/template/src/server/db/schema/disputes.ts +30 -22
  58. package/template/src/server/db/schema/notifications.ts +20 -14
  59. package/template/src/server/db/schema/orders.ts +23 -16
  60. package/template/src/server/db/schema/plugins.ts +56 -38
  61. package/template/src/server/db/schema/products.ts +24 -17
  62. package/template/src/server/db/schema/tickets.ts +44 -31
  63. package/template/src/server/db/schema/todos.ts +26 -18
  64. package/template/src/server/entries/cloudflare.ts +97 -21
  65. package/template/src/server/entries/node.ts +0 -2
  66. package/template/src/server/index.ts +0 -1
  67. package/template/src/server/isr-modules.ts +10 -0
  68. package/template/src/server/module-admin/__tests__/admin-service.test.ts +36 -12
  69. package/template/src/server/module-admin/routes/admin-notification-routes.ts +2 -1
  70. package/template/src/server/module-admin/routes/admin-routes.ts +3 -0
  71. package/template/src/server/module-admin/routes/dashboard-routes.ts +3 -0
  72. package/template/src/server/module-admin/services/admin-service.ts +57 -13
  73. package/template/src/server/module-auth/routes/auth-routes.ts +3 -0
  74. package/template/src/server/module-auth/routes/profile-routes.ts +3 -0
  75. package/template/src/server/module-captcha/routes/captcha-routes.ts +3 -0
  76. package/template/src/server/module-chat/routes/chat-routes.ts +4 -1
  77. package/template/src/server/module-content/__tests__/content-route.test.ts +2 -1
  78. package/template/src/server/module-content/__tests__/content-service.test.ts +2 -1
  79. package/template/src/server/module-content/__tests__/isr.test.ts +138 -0
  80. package/template/src/server/module-content/isr.ts +63 -0
  81. package/template/src/server/module-content/routes/content-routes.ts +10 -10
  82. package/template/src/server/module-content/routes/public-content-routes.ts +8 -4
  83. package/template/src/server/module-content/routes/topics-routes.ts +3 -0
  84. package/template/src/server/module-content/services/content-service.ts +23 -10
  85. package/template/src/server/module-dispute/__tests__/dispute-route.test.ts +2 -1
  86. package/template/src/server/module-dispute/__tests__/dispute-service.test.ts +2 -1
  87. package/template/src/server/module-dispute/routes/dispute-routes.ts +11 -10
  88. package/template/src/server/module-dispute/services/dispute-service.ts +15 -3
  89. package/template/src/server/module-file/routes/file-routes.ts +3 -0
  90. package/template/src/server/module-merchant/routes/merchant-routes.ts +3 -0
  91. package/template/src/server/module-merchant/services/merchant-service.ts +8 -8
  92. package/template/src/server/module-notifications/routes/notification-routes.ts +5 -1
  93. package/template/src/server/module-order/__tests__/order-route.test.ts +8 -8
  94. package/template/src/server/module-order/__tests__/order-service.test.ts +4 -3
  95. package/template/src/server/module-order/routes/cart-routes.ts +3 -0
  96. package/template/src/server/module-order/routes/order-routes.ts +9 -4
  97. package/template/src/server/module-order/routes/orders-mock-routes.ts +3 -0
  98. package/template/src/server/module-order/services/order-service.ts +25 -13
  99. package/template/src/server/module-permission/__tests__/audit-log-service.test.ts +464 -0
  100. package/template/src/server/module-permission/__tests__/role-service.test.ts +348 -0
  101. package/template/src/server/module-permission/routes/audit-log-routes.ts +3 -0
  102. package/template/src/server/module-permission/routes/permission-routes.ts +3 -0
  103. package/template/src/server/module-permission/routes/role-routes.ts +3 -0
  104. package/template/src/server/module-plugin/routes/plugin-admin-routes.ts +3 -0
  105. package/template/src/server/module-plugin/routes/plugin-routes.ts +3 -0
  106. package/template/src/server/module-plugin/services/admin-plugin-service.ts +10 -18
  107. package/template/src/server/module-plugin/services/admin-stats-service.ts +28 -9
  108. package/template/src/server/module-plugin/services/plugin-query-service.ts +42 -66
  109. package/template/src/server/module-tenant/routes/tenant-routes.ts +5 -1
  110. package/template/src/server/module-ticket/__tests__/ticket-route.test.ts +2 -1
  111. package/template/src/server/module-ticket/__tests__/ticket-service.test.ts +4 -3
  112. package/template/src/server/module-ticket/routes/ticket-routes.ts +11 -10
  113. package/template/src/server/module-ticket/services/ticket-service.ts +16 -5
  114. package/template/src/server/module-todos/__tests__/isr.test.ts +105 -0
  115. package/template/src/server/module-todos/__tests__/todo-service.test.ts +12 -9
  116. package/template/src/server/module-todos/__tests__/todos-route-rpc.test.ts +6 -6
  117. package/template/src/server/module-todos/isr.ts +41 -0
  118. package/template/src/server/module-todos/routes/todos-routes.ts +12 -5
  119. package/template/src/server/module-todos/services/todo-service.ts +31 -10
  120. package/template/src/server/route-registry.ts +0 -4
  121. package/template/src/server/rpc-merge.ts +27 -0
  122. package/template/src/server/rpc-surface.ts +117 -0
  123. package/template/src/server/rpc-type-canary.ts +80 -0
  124. package/template/src/server/test-utils/test-client.ts +7 -9
  125. package/template/src/server/test-utils/test-isr-helper.ts +140 -0
  126. package/template/src/shared/modules/content/schemas.ts +14 -0
  127. package/template/src/shared/modules/dispute/schemas.ts +14 -0
  128. package/template/src/shared/modules/order/schemas.ts +9 -1
  129. package/template/src/shared/modules/ticket/schemas.ts +14 -0
  130. package/template/src/shared/modules/todos/index.ts +4 -0
  131. package/template/src/shared/modules/todos/schemas.ts +14 -0
  132. package/template/src/shared/schemas/index.ts +18 -0
  133. package/template/tsup.config.ts +48 -1
  134. package/template/vitest.config.ts +19 -3
  135. package/template/vitest.setup.ts +68 -5
  136. package/template/patches/typescript+5.9.3.patch +0 -24
  137. /package/template/patches/{hono+4.12.16.patch → hono+4.12.34.patch} +0 -0
@@ -1,4 +1,4 @@
1
- import { sqliteTable, integer, text } from 'drizzle-orm/sqlite-core'
1
+ import { sqliteTable, integer, text, index } from 'drizzle-orm/sqlite-core'
2
2
  import { sql } from 'drizzle-orm'
3
3
 
4
4
  export const orderStatuses = [
@@ -10,21 +10,28 @@ export const orderStatuses = [
10
10
  ] as const
11
11
  export type OrderStatus = (typeof orderStatuses)[number]
12
12
 
13
- export const orders = sqliteTable('orders', {
14
- id: integer('id').primaryKey({ autoIncrement: true }),
15
- orderNo: text('order_no').notNull(),
16
- customerName: text('customer_name').notNull(),
17
- customerEmail: text('customer_email').notNull(),
18
- productName: text('product_name').notNull(),
19
- amount: integer('amount').notNull(),
20
- status: text('status', { enum: orderStatuses }).notNull().default('pending'),
21
- createdAt: integer('created_at', { mode: 'timestamp' })
22
- .notNull()
23
- .default(sql`(unixepoch() * 1000)`),
24
- updatedAt: integer('updated_at', { mode: 'timestamp' })
25
- .notNull()
26
- .default(sql`(unixepoch() * 1000)`),
27
- })
13
+ export const orders = sqliteTable(
14
+ 'orders',
15
+ {
16
+ id: integer('id').primaryKey({ autoIncrement: true }),
17
+ orderNo: text('order_no').notNull(),
18
+ customerName: text('customer_name').notNull(),
19
+ customerEmail: text('customer_email').notNull(),
20
+ productName: text('product_name').notNull(),
21
+ amount: integer('amount').notNull(),
22
+ status: text('status', { enum: orderStatuses }).notNull().default('pending'),
23
+ createdAt: integer('created_at', { mode: 'timestamp' })
24
+ .notNull()
25
+ .default(sql`(unixepoch() * 1000)`),
26
+ updatedAt: integer('updated_at', { mode: 'timestamp' })
27
+ .notNull()
28
+ .default(sql`(unixepoch() * 1000)`),
29
+ },
30
+ table => ({
31
+ statusIdx: index('orders_status_idx').on(table.status),
32
+ createdAtIdx: index('orders_created_at_idx').on(table.createdAt),
33
+ })
34
+ )
28
35
 
29
36
  export type OrderTable = typeof orders.$inferSelect
30
37
  export type NewOrder = typeof orders.$inferInsert
@@ -1,38 +1,47 @@
1
- import { sqliteTable, text, integer, uniqueIndex } from 'drizzle-orm/sqlite-core'
1
+ import { sqliteTable, text, integer, uniqueIndex, index } from 'drizzle-orm/sqlite-core'
2
2
  import { sql } from 'drizzle-orm'
3
3
 
4
4
  export const pluginStatus = ['pending', 'approved', 'rejected'] as const
5
5
  export type PluginStatus = (typeof pluginStatus)[number]
6
6
 
7
- export const plugins = sqliteTable('plugins', {
8
- id: text('id').primaryKey(),
9
- name: text('name').notNull(),
10
- slug: text('slug').notNull().unique(),
11
- description: text('description').notNull().default(''),
12
- readme: text('readme'),
13
- authorId: text('author_id').notNull(),
14
- authorName: text('author_name').notNull(),
15
- repositoryUrl: text('repository_url'),
16
- homepageUrl: text('homepage_url'),
17
- npmPackage: text('npm_package'),
18
- license: text('license'),
19
- version: text('version').notNull().default('0.0.1'),
20
- status: text('status', { enum: pluginStatus }).notNull().default('pending'),
21
- downloadCount: integer('download_count').notNull().default(0),
22
- viewCount: integer('view_count').notNull().default(0),
23
- featured: integer('featured', { mode: 'boolean' }).notNull().default(false),
24
- screenshotUrl: text('screenshot_url'),
25
- siteUrls: text('site_urls'),
26
- tags: text('tags'),
27
- commands: text('commands'),
28
- rejectReason: text('reject_reason'),
29
- createdAt: integer('created_at', { mode: 'timestamp' })
30
- .notNull()
31
- .default(sql`(unixepoch() * 1000)`),
32
- updatedAt: integer('updated_at', { mode: 'timestamp' })
33
- .notNull()
34
- .default(sql`(unixepoch() * 1000)`),
35
- })
7
+ export const plugins = sqliteTable(
8
+ 'plugins',
9
+ {
10
+ id: text('id').primaryKey(),
11
+ name: text('name').notNull(),
12
+ slug: text('slug').notNull().unique(),
13
+ description: text('description').notNull().default(''),
14
+ readme: text('readme'),
15
+ authorId: text('author_id').notNull(),
16
+ authorName: text('author_name').notNull(),
17
+ repositoryUrl: text('repository_url'),
18
+ homepageUrl: text('homepage_url'),
19
+ npmPackage: text('npm_package'),
20
+ license: text('license'),
21
+ version: text('version').notNull().default('0.0.1'),
22
+ status: text('status', { enum: pluginStatus }).notNull().default('pending'),
23
+ downloadCount: integer('download_count').notNull().default(0),
24
+ viewCount: integer('view_count').notNull().default(0),
25
+ featured: integer('featured', { mode: 'boolean' }).notNull().default(false),
26
+ screenshotUrl: text('screenshot_url'),
27
+ siteUrls: text('site_urls'),
28
+ tags: text('tags'),
29
+ commands: text('commands'),
30
+ rejectReason: text('reject_reason'),
31
+ createdAt: integer('created_at', { mode: 'timestamp' })
32
+ .notNull()
33
+ .default(sql`(unixepoch() * 1000)`),
34
+ updatedAt: integer('updated_at', { mode: 'timestamp' })
35
+ .notNull()
36
+ .default(sql`(unixepoch() * 1000)`),
37
+ },
38
+ table => ({
39
+ statusIdx: index('plugins_status_idx').on(table.status),
40
+ slugIdx: index('plugins_slug_idx').on(table.slug),
41
+ authorIdIdx: index('plugins_author_id_idx').on(table.authorId),
42
+ createdAtIdx: index('plugins_created_at_idx').on(table.createdAt),
43
+ })
44
+ )
36
45
 
37
46
  export const pluginVersions = sqliteTable(
38
47
  'plugin_versions',
@@ -53,6 +62,7 @@ export const pluginVersions = sqliteTable(
53
62
  },
54
63
  table => ({
55
64
  pluginVersionUnique: uniqueIndex('plugin_version_unique').on(table.pluginId, table.version),
65
+ pluginIdIdx: index('plugin_versions_plugin_id_idx').on(table.pluginId),
56
66
  })
57
67
  )
58
68
 
@@ -77,14 +87,20 @@ export const pluginReviews = sqliteTable(
77
87
  })
78
88
  )
79
89
 
80
- export const pluginCategories = sqliteTable('plugin_categories', {
81
- id: text('id').primaryKey(),
82
- name: text('name').notNull().unique(),
83
- slug: text('slug').notNull().unique(),
84
- description: text('description'),
85
- icon: text('icon'),
86
- sortOrder: integer('sort_order').notNull().default(0),
87
- })
90
+ export const pluginCategories = sqliteTable(
91
+ 'plugin_categories',
92
+ {
93
+ id: text('id').primaryKey(),
94
+ name: text('name').notNull().unique(),
95
+ slug: text('slug').notNull().unique(),
96
+ description: text('description'),
97
+ icon: text('icon'),
98
+ sortOrder: integer('sort_order').notNull().default(0),
99
+ },
100
+ table => ({
101
+ slugIdx: index('plugin_categories_slug_idx').on(table.slug),
102
+ })
103
+ )
88
104
 
89
105
  export const pluginCategoryMappings = sqliteTable(
90
106
  'plugin_category_mappings',
@@ -101,6 +117,8 @@ export const pluginCategoryMappings = sqliteTable(
101
117
  table.pluginId,
102
118
  table.categoryId
103
119
  ),
120
+ categoryIdIdx: index('plugin_category_mappings_category_id_idx').on(table.categoryId),
121
+ pluginIdIdx: index('plugin_category_mappings_plugin_id_idx').on(table.pluginId),
104
122
  })
105
123
  )
106
124
 
@@ -1,25 +1,32 @@
1
- import { sqliteTable, integer, text } from 'drizzle-orm/sqlite-core'
1
+ import { sqliteTable, integer, text, index } from 'drizzle-orm/sqlite-core'
2
2
  import { sql } from 'drizzle-orm'
3
3
 
4
4
  export const productStatuses = ['active', 'inactive', 'out_of_stock'] as const
5
5
  export type ProductStatus = (typeof productStatuses)[number]
6
6
 
7
- export const products = sqliteTable('products', {
8
- id: text('id').primaryKey(),
9
- name: text('name').notNull(),
10
- description: text('description'),
11
- price: integer('price').notNull(), // 单位:分
12
- status: text('status', { enum: productStatuses }).notNull().default('active'),
13
- stock: integer('stock').notNull().default(0),
14
- imageUrl: text('image_url'),
15
- merchantId: integer('merchant_id').notNull(), // Changed to integer to match merchants.id
16
- createdAt: integer('created_at', { mode: 'timestamp' })
17
- .notNull()
18
- .default(sql`(unixepoch() * 1000)`),
19
- updatedAt: integer('updated_at', { mode: 'timestamp' })
20
- .notNull()
21
- .default(sql`(unixepoch() * 1000)`),
22
- })
7
+ export const products = sqliteTable(
8
+ 'products',
9
+ {
10
+ id: text('id').primaryKey(),
11
+ name: text('name').notNull(),
12
+ description: text('description'),
13
+ price: integer('price').notNull(), // 单位:分
14
+ status: text('status', { enum: productStatuses }).notNull().default('active'),
15
+ stock: integer('stock').notNull().default(0),
16
+ imageUrl: text('image_url'),
17
+ merchantId: integer('merchant_id').notNull(), // Changed to integer to match merchants.id
18
+ createdAt: integer('created_at', { mode: 'timestamp' })
19
+ .notNull()
20
+ .default(sql`(unixepoch() * 1000)`),
21
+ updatedAt: integer('updated_at', { mode: 'timestamp' })
22
+ .notNull()
23
+ .default(sql`(unixepoch() * 1000)`),
24
+ },
25
+ table => ({
26
+ statusIdx: index('products_status_idx').on(table.status),
27
+ merchantIdIdx: index('products_merchant_id_idx').on(table.merchantId),
28
+ })
29
+ )
23
30
 
24
31
  export type Product = typeof products.$inferSelect
25
32
  export type NewProduct = typeof products.$inferInsert
@@ -1,4 +1,4 @@
1
- import { sqliteTable, integer, text } from 'drizzle-orm/sqlite-core'
1
+ import { sqliteTable, integer, text, index } from 'drizzle-orm/sqlite-core'
2
2
  import { sql } from 'drizzle-orm'
3
3
 
4
4
  export const ticketStatuses = [
@@ -24,37 +24,50 @@ export type TicketCategory = (typeof ticketCategories)[number]
24
24
 
25
25
  export const ticketReplyAuthorRoles = ['customer', 'admin', 'system'] as const
26
26
 
27
- export const tickets = sqliteTable('tickets', {
28
- id: integer('id').primaryKey({ autoIncrement: true }),
29
- ticketNo: text('ticket_no').notNull(),
30
- customerName: text('customer_name').notNull(),
31
- customerEmail: text('customer_email').notNull(),
32
- subject: text('subject').notNull(),
33
- description: text('description').notNull(),
34
- status: text('status', { enum: ticketStatuses }).notNull().default('open'),
35
- priority: text('priority', { enum: ticketPriorities }).notNull().default('medium'),
36
- category: text('category', { enum: ticketCategories }).notNull(),
37
- assignedTo: text('assigned_to'),
38
- createdAt: integer('created_at', { mode: 'timestamp' })
39
- .notNull()
40
- .default(sql`(unixepoch() * 1000)`),
41
- updatedAt: integer('updated_at', { mode: 'timestamp' })
42
- .notNull()
43
- .default(sql`(unixepoch() * 1000)`),
44
- })
27
+ export const tickets = sqliteTable(
28
+ 'tickets',
29
+ {
30
+ id: integer('id').primaryKey({ autoIncrement: true }),
31
+ ticketNo: text('ticket_no').notNull(),
32
+ customerName: text('customer_name').notNull(),
33
+ customerEmail: text('customer_email').notNull(),
34
+ subject: text('subject').notNull(),
35
+ description: text('description').notNull(),
36
+ status: text('status', { enum: ticketStatuses }).notNull().default('open'),
37
+ priority: text('priority', { enum: ticketPriorities }).notNull().default('medium'),
38
+ category: text('category', { enum: ticketCategories }).notNull(),
39
+ assignedTo: text('assigned_to'),
40
+ createdAt: integer('created_at', { mode: 'timestamp' })
41
+ .notNull()
42
+ .default(sql`(unixepoch() * 1000)`),
43
+ updatedAt: integer('updated_at', { mode: 'timestamp' })
44
+ .notNull()
45
+ .default(sql`(unixepoch() * 1000)`),
46
+ },
47
+ table => ({
48
+ statusIdx: index('tickets_status_idx').on(table.status),
49
+ createdAtIdx: index('tickets_created_at_idx').on(table.createdAt),
50
+ })
51
+ )
45
52
 
46
- export const ticketReplies = sqliteTable('ticket_replies', {
47
- id: integer('id').primaryKey({ autoIncrement: true }),
48
- ticketId: integer('ticket_id')
49
- .notNull()
50
- .references(() => tickets.id, { onDelete: 'cascade' }),
51
- content: text('content').notNull(),
52
- author: text('author').notNull(),
53
- isCustomer: integer('is_customer', { mode: 'boolean' }).notNull().default(false),
54
- createdAt: integer('created_at', { mode: 'timestamp' })
55
- .notNull()
56
- .default(sql`(unixepoch() * 1000)`),
57
- })
53
+ export const ticketReplies = sqliteTable(
54
+ 'ticket_replies',
55
+ {
56
+ id: integer('id').primaryKey({ autoIncrement: true }),
57
+ ticketId: integer('ticket_id')
58
+ .notNull()
59
+ .references(() => tickets.id, { onDelete: 'cascade' }),
60
+ content: text('content').notNull(),
61
+ author: text('author').notNull(),
62
+ isCustomer: integer('is_customer', { mode: 'boolean' }).notNull().default(false),
63
+ createdAt: integer('created_at', { mode: 'timestamp' })
64
+ .notNull()
65
+ .default(sql`(unixepoch() * 1000)`),
66
+ },
67
+ table => ({
68
+ ticketIdIdx: index('ticket_replies_ticket_id_idx').on(table.ticketId),
69
+ })
70
+ )
58
71
 
59
72
  export type TicketTable = typeof tickets.$inferSelect
60
73
  export type NewTicket = typeof tickets.$inferInsert
@@ -1,21 +1,29 @@
1
- import { sqliteTable, integer, text } from 'drizzle-orm/sqlite-core';
2
- import { sql } from 'drizzle-orm';
1
+ import { sqliteTable, integer, text, index } from 'drizzle-orm/sqlite-core'
2
+ import { sql } from 'drizzle-orm'
3
3
 
4
- export const todoStatus = ['pending', 'in_progress', 'completed'] as const;
5
- export type TodoStatus = (typeof todoStatus)[number];
4
+ export const todoStatus = ['pending', 'in_progress', 'completed'] as const
5
+ export type TodoStatus = (typeof todoStatus)[number]
6
6
 
7
- export const todos = sqliteTable('todos', {
8
- id: integer('id').primaryKey({ autoIncrement: true }),
9
- title: text('title').notNull(),
10
- description: text('description'),
11
- status: text('status', { enum: todoStatus }).notNull().default('pending'),
12
- createdAt: integer('created_at', { mode: 'timestamp' })
13
- .notNull()
14
- .default(sql`(unixepoch() * 1000)`),
15
- updatedAt: integer('updated_at', { mode: 'timestamp' })
16
- .notNull()
17
- .default(sql`(unixepoch() * 1000)`),
18
- });
7
+ export const todos = sqliteTable(
8
+ 'todos',
9
+ {
10
+ id: integer('id').primaryKey({ autoIncrement: true }),
11
+ title: text('title').notNull(),
12
+ description: text('description'),
13
+ status: text('status', { enum: todoStatus }).notNull().default('pending'),
14
+ createdAt: integer('created_at', { mode: 'timestamp' })
15
+ .notNull()
16
+ .default(sql`(unixepoch() * 1000)`),
17
+ updatedAt: integer('updated_at', { mode: 'timestamp' })
18
+ .notNull()
19
+ .default(sql`(unixepoch() * 1000)`),
20
+ },
21
+ table => ({
22
+ statusIdx: index('todos_status_idx').on(table.status),
23
+ createdAtIdx: index('todos_created_at_idx').on(table.createdAt),
24
+ updatedAtIdx: index('todos_updated_at_idx').on(table.updatedAt),
25
+ })
26
+ )
19
27
 
20
- export type TodoTable = typeof todos.$inferSelect;
21
- export type NewTodo = typeof todos.$inferInsert;
28
+ export type TodoTable = typeof todos.$inferSelect
29
+ export type NewTodo = typeof todos.$inferInsert
@@ -1,12 +1,8 @@
1
1
  /**
2
2
  * @framework-baseline e350401421193896
3
3
  * @framework-modify
4
- * @reason 集成 ISR 缓存层,为页面路由提供增量静态再生能力
5
- * @impact 影响 Cloudflare Workers 环境的页面响应流程,新增 ISR 缓存查找/存储/失效逻辑
6
- *
7
- * Note: In Cloudflare Workers, each request runs in its own isolate,
8
- * so globalThis is request-scoped and there's no race condition risk.
9
- * The middleware sets the DB binding for each request.
4
+ * @reason 模块化 ISR 改造 + SSR 渲染:调用 renderSSR 生成 React body,注入到 ISR 模板
5
+ * @impact CF 入口集成 React SSR,ISR 同时负责 SEO meta 标签和 body 渲染
10
6
  */
11
7
 
12
8
  import { createApp } from '../app'
@@ -16,8 +12,15 @@ import { RealtimeDurableObject } from '@server/core'
16
12
  import { setRuntimeAdapter } from '@server/core/runtime'
17
13
  import { getCloudflareRuntimeAdapter } from '@server/core/runtime-cloudflare'
18
14
  import { createISRCache, isISRRoute } from '@server/core/isr-cache'
19
- import { renderPage } from '@server/core/ssr-renderer'
20
15
  import { setISRCache } from '@server/core/isr-invalidation'
16
+ import { isrRegistry, type ISRRouterContext } from '@server/core/isr-registry'
17
+ import { renderISRPage } from '@server/core/isr-renderer'
18
+ import { renderSSR } from '@client/entry-server'
19
+
20
+ // Import module ISR registrations (side-effect: registers routes).
21
+ // 由 CLI 按 preset 生成的汇总文件(src/server/isr-modules.ts),
22
+ // 避免 content 等模块被裁剪后悬空导入导致 CF 构建/tsc 失败。
23
+ import '@server/isr-modules'
21
24
 
22
25
  export interface CloudflareBindings extends AppBindings {
23
26
  DB: D1Database
@@ -36,6 +39,8 @@ const app = createApp<CloudflareBindings>()
36
39
  const isrCache = createISRCache()
37
40
  setISRCache(isrCache)
38
41
 
42
+ let cachedTemplate: string | null = null
43
+
39
44
  const wrappedApp = app
40
45
  .use('*', async (c, next) => {
41
46
  ;(globalThis as unknown as { DB: D1Database }).DB = c.env.DB
@@ -102,19 +107,20 @@ export default {
102
107
  }
103
108
 
104
109
  if (result.status === 'stale' && result.html) {
105
- ctx.waitUntil(regeneratePage(pathname, env))
110
+ ctx.waitUntil(regeneratePage(pathname, env, request))
106
111
  return new Response(result.html, {
107
112
  headers: { 'Content-Type': 'text/html;charset=UTF-8', 'X-ISR-Status': 'stale' },
108
113
  })
109
114
  }
110
115
 
111
- const rendered = await renderPage(pathname)
112
-
113
- ctx.waitUntil(isrCache.store(pathname, rendered.html))
114
-
115
- return new Response(rendered.html, {
116
- status: rendered.status,
117
- headers: { ...rendered.headers, 'X-ISR-Status': 'miss' },
116
+ const html = await renderISRForRoute(pathname, env, request)
117
+ ctx.waitUntil(isrCache.store(pathname, html))
118
+ return new Response(html, {
119
+ headers: {
120
+ 'Content-Type': 'text/html;charset=UTF-8',
121
+ 'X-ISR-Status': 'miss',
122
+ 'X-ISR-Rendered': 'true',
123
+ },
118
124
  })
119
125
  }
120
126
 
@@ -129,15 +135,85 @@ export default {
129
135
  },
130
136
  }
131
137
 
132
- async function regeneratePage(pathname: string, _env: CloudflareBindings): Promise<void> {
138
+ // 单飞:同 isolate 内并发 stale 请求共享同一次重建,防缓存击穿
139
+ const inflightRevalidations = new Map<string, Promise<void>>()
140
+
141
+ function regeneratePage(
142
+ pathname: string,
143
+ env: CloudflareBindings,
144
+ request: Request
145
+ ): Promise<void> {
146
+ const existing = inflightRevalidations.get(pathname)
147
+ if (existing) return existing
148
+
149
+ const task = (async () => {
150
+ try {
151
+ const html = await renderISRForRoute(pathname, env, request)
152
+ await isrCache.store(pathname, html)
153
+ } catch (error) {
154
+ console.error('ISR regeneration failed:', error)
155
+ } finally {
156
+ inflightRevalidations.delete(pathname)
157
+ }
158
+ })()
159
+
160
+ inflightRevalidations.set(pathname, task)
161
+ return task
162
+ }
163
+
164
+ async function renderISRForRoute(
165
+ pathname: string,
166
+ env: CloudflareBindings,
167
+ request: Request
168
+ ): Promise<string> {
169
+ const entry = isrRegistry.match(pathname)
170
+ if (!entry) {
171
+ throw new Error(`No ISR handler for ${pathname}`)
172
+ }
173
+
174
+ const ctx: ISRRouterContext = { db: env.DB, env }
175
+ let data: unknown = {}
176
+ let meta = { title: 'Biomimic App', description: 'A full-stack application template' }
177
+
178
+ try {
179
+ data = await entry.fetch(pathname, ctx)
180
+ meta = entry.meta(data, pathname)
181
+ } catch {
182
+ // DB error — fall through with default meta
183
+ }
184
+
185
+ if (!cachedTemplate && env.ASSETS) {
186
+ try {
187
+ const indexUrl = new URL('/index.html', request.url).href
188
+ const resp = await env.ASSETS.fetch(new Request(indexUrl))
189
+ if (resp.ok) {
190
+ cachedTemplate = await resp.text()
191
+ }
192
+ } catch {
193
+ // fallback below
194
+ }
195
+ }
196
+
197
+ // Render React SSR body
198
+ let body = ''
133
199
  try {
134
- const rendered = await renderPage(pathname)
135
- await isrCache.store(pathname, rendered.html)
136
- } catch (error) {
137
- console.error('ISR regeneration failed:', error)
200
+ const ssrResult = renderSSR(pathname, data as Parameters<typeof renderSSR>[1])
201
+ body = ssrResult.html
202
+ // Helmet takes priority for title/meta
203
+ const helmetTitle = ssrResult.helmet.title
204
+ ?.replace(/<title[^>]*>/, '')
205
+ ?.replace(/<\/title>/, '')
206
+ ?.trim()
207
+ if (helmetTitle) {
208
+ meta = { ...meta, title: helmetTitle }
209
+ }
210
+ } catch (e) {
211
+ console.error('SSR render failed:', e)
212
+ // Fallback: empty body, SPA will hydrate
138
213
  }
214
+
215
+ return renderISRPage({ template: cachedTemplate, body, meta })
139
216
  }
140
217
 
141
218
  export { isrCache }
142
219
  export { RealtimeDurableObject, getDb }
143
- export type AppType = typeof wrappedApp
@@ -168,8 +168,6 @@ app.onError((err, c) => {
168
168
  })
169
169
 
170
170
  export default app
171
- export type AppType = typeof app
172
-
173
171
  export async function createServer() {
174
172
  const server = serve({
175
173
  fetch: app.fetch,
@@ -59,4 +59,3 @@ export { createApp } from './app'
59
59
  export { type AppBindings, type CreateAppOptions } from './types/bindings'
60
60
  export { getAppConfig, getDatabaseConfig, type AppConfig, type DatabaseConfig } from './config'
61
61
  export { createServer, startServer } from './entries/node'
62
- export type { ClientApiType, AdminApiType, AppType } from './app'
@@ -0,0 +1,10 @@
1
+ /**
2
+ * ISR 模块注册汇总(单一入口,供 entries/cloudflare.ts 引用)。
3
+ *
4
+ * 本文件在模板仓库中为全量版本;CLI 脚手架时会按 preset 重新生成
5
+ * (src/generators/isr-modules.ts),只保留选中模块的导入,
6
+ * 避免 content 等模块被裁剪后悬空导入导致 CF 构建/tsc 失败。
7
+ */
8
+
9
+ import '@server/module-todos/isr'
10
+ import '@server/module-content/isr'