honertia 0.1.38 → 0.1.41

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 (60) hide show
  1. package/README.md +536 -13
  2. package/dist/__tmp_db_safety_probe.d.ts +2 -0
  3. package/dist/__tmp_db_safety_probe.d.ts.map +1 -0
  4. package/dist/__tmp_db_safety_probe.js +13 -0
  5. package/dist/__tmp_scoped_astrusted_probe.d.ts +2 -0
  6. package/dist/__tmp_scoped_astrusted_probe.d.ts.map +1 -0
  7. package/dist/__tmp_scoped_astrusted_probe.js +10 -0
  8. package/dist/__tmp_scoped_derived_probe.d.ts +2 -0
  9. package/dist/__tmp_scoped_derived_probe.d.ts.map +1 -0
  10. package/dist/__tmp_scoped_derived_probe.js +12 -0
  11. package/dist/__tmp_scoped_mutation_probe.d.ts +2 -0
  12. package/dist/__tmp_scoped_mutation_probe.d.ts.map +1 -0
  13. package/dist/__tmp_scoped_mutation_probe.js +15 -0
  14. package/dist/cache.d.ts +78 -7
  15. package/dist/cache.d.ts.map +1 -1
  16. package/dist/cache.js +152 -23
  17. package/dist/cli/bin.d.ts +8 -0
  18. package/dist/cli/bin.d.ts.map +1 -0
  19. package/dist/cli/bin.js +150 -0
  20. package/dist/cli/db.d.ts.map +1 -1
  21. package/dist/cli/db.js +83 -18
  22. package/dist/cli/feature.d.ts +1 -0
  23. package/dist/cli/feature.d.ts.map +1 -1
  24. package/dist/cli/feature.js +76 -11
  25. package/dist/cli/generate.d.ts.map +1 -1
  26. package/dist/cli/generate.js +124 -22
  27. package/dist/cli/index.d.ts +6 -2
  28. package/dist/cli/index.d.ts.map +1 -1
  29. package/dist/cli/index.js +16 -4
  30. package/dist/cli/inline-tests.d.ts.map +1 -1
  31. package/dist/cli/inline-tests.js +4 -0
  32. package/dist/cli/openapi.d.ts +2 -1
  33. package/dist/cli/openapi.d.ts.map +1 -1
  34. package/dist/cli/openapi.js +68 -3
  35. package/dist/effect/action.d.ts +92 -3
  36. package/dist/effect/action.d.ts.map +1 -1
  37. package/dist/effect/action.js +38 -32
  38. package/dist/effect/auth.d.ts +3 -1
  39. package/dist/effect/auth.d.ts.map +1 -1
  40. package/dist/effect/auth.js +17 -3
  41. package/dist/effect/bridge.d.ts +7 -2
  42. package/dist/effect/bridge.d.ts.map +1 -1
  43. package/dist/effect/bridge.js +48 -4
  44. package/dist/effect/index.d.ts +4 -4
  45. package/dist/effect/index.d.ts.map +1 -1
  46. package/dist/effect/index.js +3 -3
  47. package/dist/effect/routing.d.ts.map +1 -1
  48. package/dist/effect/routing.js +14 -14
  49. package/dist/effect/services.d.ts +67 -0
  50. package/dist/effect/services.d.ts.map +1 -1
  51. package/dist/effect/services.js +20 -0
  52. package/dist/effect/validation.d.ts +10 -1
  53. package/dist/effect/validation.d.ts.map +1 -1
  54. package/dist/effect/validation.js +49 -14
  55. package/dist/schema.d.ts +1 -1
  56. package/dist/schema.d.ts.map +1 -1
  57. package/dist/schema.js +1 -1
  58. package/dist/setup.d.ts.map +1 -1
  59. package/dist/setup.js +3 -2
  60. package/package.json +4 -1
package/README.md CHANGED
@@ -4,6 +4,14 @@ Inertia.js adapter for Hono with Effect.ts. Server-driven app with SPA behavior.
4
4
 
5
5
  ## CLI Commands
6
6
 
7
+ `honertia` is shipped as a package binary. You can run commands with:
8
+
9
+ ```bash
10
+ bunx honertia <command>
11
+ # or
12
+ npx honertia <command>
13
+ ```
14
+
7
15
  ### Generate Action
8
16
 
9
17
  ```bash
@@ -117,8 +125,8 @@ honertia db status # Show migration status
117
125
  honertia db status --json # JSON output
118
126
  honertia db migrate # Run pending migrations
119
127
  honertia db migrate --preview # Preview SQL without executing
120
- honertia db rollback # Rollback last migration
121
- honertia db rollback --preview # Preview rollback SQL
128
+ honertia db rollback --preview # Preview rollback SQL for latest applied migration
129
+ # Non-preview rollback execution is manual (run preview SQL yourself)
122
130
  honertia db generate add_email # Generate new migration
123
131
  ```
124
132
 
@@ -824,7 +832,7 @@ export const listProjects = action(
824
832
  })
825
833
  ),
826
834
  S.Array(ProjectSchema),
827
- Duration.minutes(5)
835
+ { ttl: Duration.minutes(5) }
828
836
  )
829
837
 
830
838
  return yield* render('Projects/Index', { projects: userProjects })
@@ -979,6 +987,93 @@ export const destroyProject = action(
979
987
  )
980
988
  ```
981
989
 
990
+ ### Real-World Transaction (Order Checkout)
991
+
992
+ Pass a validated/trusted transaction object as the second argument to
993
+ `dbMutation`/`dbTransaction` when you want strict write scoping.
994
+ Inside that callback, write methods only accept values that come from
995
+ that scoped object.
996
+
997
+ ```typescript
998
+ import { Effect, Schema as S } from 'effect'
999
+ import {
1000
+ action,
1001
+ authorize,
1002
+ validate,
1003
+ validateRequest,
1004
+ DatabaseService,
1005
+ dbTransaction,
1006
+ mergeMutationInput,
1007
+ redirect,
1008
+ requiredString,
1009
+ } from 'honertia/effect'
1010
+ import { eq } from 'drizzle-orm'
1011
+ import { orders, orderItems, inventory } from '~/db/schema'
1012
+
1013
+ const CheckoutSchema = S.Struct({
1014
+ productId: requiredString,
1015
+ quantity: S.NumberFromString,
1016
+ })
1017
+
1018
+ const CheckoutTransactionSchema = S.Struct({
1019
+ createOrder: S.Struct({
1020
+ userId: S.String,
1021
+ status: S.Literal('pending'),
1022
+ }),
1023
+ createItem: S.Struct({
1024
+ productId: S.String,
1025
+ quantity: S.Number,
1026
+ // Reserve transaction-derived fields you plan to fill later.
1027
+ orderId: S.optional(S.String),
1028
+ }),
1029
+ updateInventory: S.Struct({
1030
+ reserved: S.Number,
1031
+ }),
1032
+ })
1033
+
1034
+ export const checkout = action(
1035
+ Effect.gen(function* () {
1036
+ const auth = yield* authorize()
1037
+ const input = yield* validateRequest(CheckoutSchema, {
1038
+ errorComponent: 'Checkout/Show',
1039
+ })
1040
+ const db = yield* DatabaseService
1041
+
1042
+ const txInput = yield* validate(CheckoutTransactionSchema, {
1043
+ createOrder: {
1044
+ userId: auth.user.id,
1045
+ status: 'pending',
1046
+ },
1047
+ createItem: {
1048
+ productId: input.productId,
1049
+ quantity: input.quantity,
1050
+ },
1051
+ updateInventory: {
1052
+ reserved: input.quantity,
1053
+ },
1054
+ })
1055
+ // For untyped/unknown payloads (e.g. external JSON), use validateUnknown(schema, raw)
1056
+
1057
+ const order = yield* dbTransaction(db, txInput, async (tx, scoped) => {
1058
+ const [created] = await tx.insert(orders).values(scoped.createOrder).returning()
1059
+
1060
+ const itemInsert = mergeMutationInput(scoped.createItem, {
1061
+ orderId: created.id,
1062
+ })
1063
+ await tx.insert(orderItems).values(itemInsert)
1064
+
1065
+ await tx.update(inventory)
1066
+ .set(scoped.updateInventory)
1067
+ .where(eq(inventory.productId, scoped.createItem.productId))
1068
+
1069
+ return created
1070
+ })
1071
+
1072
+ return yield* redirect(`/orders/${order.id}`)
1073
+ })
1074
+ )
1075
+ ```
1076
+
982
1077
  ### API Endpoint (JSON Response)
983
1078
 
984
1079
  ```typescript
@@ -1676,7 +1771,7 @@ export const listProjects = action(
1676
1771
  catch: (error) => new Error(String(error)),
1677
1772
  }),
1678
1773
  S.Array(ProjectSchema),
1679
- Duration.minutes(5)
1774
+ { ttl: Duration.minutes(5) }
1680
1775
  )
1681
1776
 
1682
1777
  return yield* render('Projects/Index', { projects: userProjects })
@@ -1688,12 +1783,19 @@ export const listProjects = action(
1688
1783
 
1689
1784
  | Function | Description |
1690
1785
  |----------|-------------|
1691
- | `cache(key, compute, schema, ttl)` | Get from cache or compute and store |
1786
+ | `cache(key, compute, schema, options)` | Get from cache or compute and store |
1692
1787
  | `cacheGet(key, schema)` | Get value from cache (returns `Option`) |
1693
- | `cacheSet(key, value, schema, ttl)` | Store value in cache |
1788
+ | `cacheSet(key, value, schema, options)` | Store value in cache |
1694
1789
  | `cacheInvalidate(key)` | Delete a single cache key |
1695
1790
  | `cacheInvalidatePrefix(prefix)` | Delete all keys with prefix |
1696
1791
 
1792
+ The `options` parameter is an object with the following properties:
1793
+
1794
+ | Property | Type | Description |
1795
+ |----------|------|-------------|
1796
+ | `ttl` | `Duration.DurationInput` | Time-to-live for cached values (required) |
1797
+ | `swr` | `Duration.DurationInput` | Stale-while-revalidate window (optional) |
1798
+
1697
1799
  ### Cache Invalidation
1698
1800
 
1699
1801
  Invalidate cache when data changes:
@@ -1783,7 +1885,7 @@ if (Option.isSome(cached)) {
1783
1885
  const user = yield* fetchUser(id)
1784
1886
 
1785
1887
  // Store in cache
1786
- yield* cacheSet(`user:${id}`, user, UserSchema, Duration.hours(1))
1888
+ yield* cacheSet(`user:${id}`, user, UserSchema, { ttl: Duration.hours(1) })
1787
1889
 
1788
1890
  return user
1789
1891
  ```
@@ -1812,7 +1914,8 @@ const handler = action(
1812
1914
  yield* cache.delete('my-key')
1813
1915
 
1814
1916
  // List keys by prefix
1815
- const keys = yield* cache.list({ prefix: 'user:' })
1917
+ const page = yield* cache.list({ prefix: 'user:' })
1918
+ const keys = page.keys
1816
1919
  })
1817
1920
  )
1818
1921
  ```
@@ -1850,7 +1953,10 @@ const createRedisCacheClient = (redisUrl: string): CacheClient => {
1850
1953
  Effect.tryPromise({
1851
1954
  try: async () => {
1852
1955
  const keys = await client.keys(options?.prefix ? `${options.prefix}*` : '*')
1853
- return { keys: keys.map((name) => ({ name })) }
1956
+ return {
1957
+ keys: keys.map((name) => ({ name })),
1958
+ list_complete: true,
1959
+ }
1854
1960
  },
1855
1961
  catch: (e) => new CacheClientError('Redis keys failed', e),
1856
1962
  }),
@@ -1900,6 +2006,7 @@ const makeTestCache = (): Layer.Layer<CacheService> => {
1900
2006
  keys: [...store.keys()]
1901
2007
  .filter((k) => !options?.prefix || k.startsWith(options.prefix))
1902
2008
  .map((name) => ({ name })),
2009
+ list_complete: true,
1903
2010
  })),
1904
2011
  }
1905
2012
 
@@ -1921,8 +2028,8 @@ describe('cache', () => {
1921
2028
  return { id: '1', name: 'Test' }
1922
2029
  })
1923
2030
 
1924
- const first = yield* cache('test:1', compute, TestSchema, Duration.hours(1))
1925
- const second = yield* cache('test:1', compute, TestSchema, Duration.hours(1))
2031
+ const first = yield* cache('test:1', compute, TestSchema, { ttl: Duration.hours(1) })
2032
+ const second = yield* cache('test:1', compute, TestSchema, { ttl: Duration.hours(1) })
1926
2033
 
1927
2034
  expect(first).toEqual(second)
1928
2035
  expect(callCount).toBe(1) // Only computed once
@@ -1937,9 +2044,9 @@ describe('cache', () => {
1937
2044
  return { id: '1', name: `Call ${callCount}` }
1938
2045
  })
1939
2046
 
1940
- yield* cache('test:1', compute, TestSchema, Duration.hours(1))
2047
+ yield* cache('test:1', compute, TestSchema, { ttl: Duration.hours(1) })
1941
2048
  yield* cacheInvalidate('test:1')
1942
- yield* cache('test:1', compute, TestSchema, Duration.hours(1))
2049
+ yield* cache('test:1', compute, TestSchema, { ttl: Duration.hours(1) })
1943
2050
 
1944
2051
  expect(callCount).toBe(2) // Computed twice
1945
2052
  }).pipe(Effect.provide(makeTestCache()), Effect.runPromise))
@@ -1979,6 +2086,421 @@ Recommended cache key patterns:
1979
2086
  `api:exchange:${currency}`
1980
2087
  ```
1981
2088
 
2089
+ ### Stale-While-Revalidate (SWR)
2090
+
2091
+ The cache supports the stale-while-revalidate pattern for improved latency and resilience. When enabled, stale values are returned immediately while a background refresh is triggered. In environments without `ExecutionContext`, stale entries are recomputed synchronously instead.
2092
+
2093
+ ```typescript
2094
+ import { Effect, Duration } from 'effect'
2095
+ import { cache, DatabaseService } from 'honertia/effect'
2096
+
2097
+ const UserSchema = S.Struct({
2098
+ id: S.String,
2099
+ name: S.String,
2100
+ email: S.String,
2101
+ })
2102
+
2103
+ // Basic usage with TTL only
2104
+ const user = yield* cache(
2105
+ `user:${id}`,
2106
+ fetchUser(id),
2107
+ UserSchema,
2108
+ { ttl: Duration.hours(1) }
2109
+ )
2110
+
2111
+ // With stale-while-revalidate
2112
+ const user = yield* cache(
2113
+ `user:${id}`,
2114
+ fetchUser(id),
2115
+ UserSchema,
2116
+ {
2117
+ ttl: Duration.hours(1), // Fresh for 1 hour
2118
+ swr: Duration.minutes(5), // Serve stale for 5 more minutes while refreshing
2119
+ }
2120
+ )
2121
+ ```
2122
+
2123
+ **How SWR works:**
2124
+
2125
+ | Cache State | Behavior |
2126
+ |-------------|----------|
2127
+ | Fresh (age < TTL) | Return cached value immediately |
2128
+ | Stale (TTL < age < TTL + SWR) | Return stale value immediately, trigger background refresh |
2129
+ | Expired (age > TTL + SWR) | Compute new value synchronously |
2130
+ | Cold (no cache) | Compute new value synchronously |
2131
+
2132
+ **Benefits:**
2133
+ - **Faster responses**: Users always get an immediate response (stale or fresh)
2134
+ - **Reduced latency spikes**: No waiting for slow database queries during cache refresh
2135
+ - **Graceful degradation**: If the background refresh fails, stale data is still served
2136
+
2137
+ **Real-world example:**
2138
+
2139
+ ```typescript
2140
+ import { Effect, Duration, Schema as S } from 'effect'
2141
+ import { action, authorize, cache, render, DatabaseService } from 'honertia/effect'
2142
+ import { eq } from 'drizzle-orm'
2143
+ import { projects } from '~/db/schema'
2144
+
2145
+ const ProjectListSchema = S.Array(
2146
+ S.Struct({
2147
+ id: S.String,
2148
+ name: S.String,
2149
+ createdAt: S.Date,
2150
+ })
2151
+ )
2152
+
2153
+ export const indexProjects = action(
2154
+ Effect.gen(function* () {
2155
+ const auth = yield* authorize()
2156
+ const db = yield* DatabaseService
2157
+
2158
+ // Cache project list with SWR
2159
+ // - Fresh for 5 minutes
2160
+ // - Serve stale for 1 additional minute while refreshing in background
2161
+ const userProjects = yield* cache(
2162
+ `projects:user:${auth.user.id}`,
2163
+ Effect.tryPromise(() =>
2164
+ db.query.projects.findMany({
2165
+ where: eq(projects.userId, auth.user.id),
2166
+ orderBy: (p, { desc }) => [desc(p.createdAt)],
2167
+ })
2168
+ ),
2169
+ ProjectListSchema,
2170
+ {
2171
+ ttl: Duration.minutes(5),
2172
+ swr: Duration.minutes(1),
2173
+ }
2174
+ )
2175
+
2176
+ return yield* render('Projects/Index', { projects: userProjects })
2177
+ })
2178
+ )
2179
+ ```
2180
+
2181
+ ### Background Tasks with ExecutionContextService
2182
+
2183
+ The `ExecutionContextService` provides access to Cloudflare Workers' `waitUntil` API, allowing you to run tasks after the response is sent. This is automatically used by the cache's SWR feature for background refresh.
2184
+
2185
+ ```typescript
2186
+ import { Effect } from 'effect'
2187
+ import { action, ExecutionContextService, authorize, render } from 'honertia/effect'
2188
+
2189
+ export const dashboard = action(
2190
+ Effect.gen(function* () {
2191
+ const auth = yield* authorize()
2192
+ const ctx = yield* ExecutionContextService
2193
+
2194
+ // Send analytics in background - doesn't block response
2195
+ yield* ctx.runInBackground(
2196
+ Effect.tryPromise(() =>
2197
+ fetch('https://analytics.example.com/events', {
2198
+ method: 'POST',
2199
+ body: JSON.stringify({
2200
+ event: 'page_view',
2201
+ userId: auth.user.id,
2202
+ page: 'dashboard',
2203
+ timestamp: Date.now(),
2204
+ }),
2205
+ })
2206
+ )
2207
+ )
2208
+
2209
+ return yield* render('Dashboard', { user: auth.user })
2210
+ })
2211
+ )
2212
+ ```
2213
+
2214
+ **ExecutionContextService API:**
2215
+
2216
+ | Method | Description |
2217
+ |--------|-------------|
2218
+ | `isAvailable` | `boolean` - Whether background execution is available |
2219
+ | `runInBackground(effect)` | Run an Effect after the response is sent |
2220
+ | `waitUntil(promise)` | Raw `waitUntil` for external promises |
2221
+
2222
+ **Common use cases:**
2223
+
2224
+ ```typescript
2225
+ import { Effect } from 'effect'
2226
+ import {
2227
+ ExecutionContextService,
2228
+ authorize,
2229
+ DatabaseService,
2230
+ dbMutation,
2231
+ asTrusted,
2232
+ BindingsService,
2233
+ } from 'honertia/effect'
2234
+
2235
+ // Audit logging
2236
+ const auditLog = (action: string, details: Record<string, unknown>) =>
2237
+ Effect.gen(function* () {
2238
+ const ctx = yield* ExecutionContextService
2239
+ const user = yield* authorize()
2240
+ const db = yield* DatabaseService
2241
+
2242
+ yield* ctx.runInBackground(
2243
+ dbMutation(db, async (tx) => {
2244
+ await tx.insert(auditLogs).values(asTrusted({
2245
+ userId: user.user.id,
2246
+ action,
2247
+ details,
2248
+ timestamp: new Date(),
2249
+ }))
2250
+ })
2251
+ )
2252
+ })
2253
+
2254
+ // Webhook delivery with retries
2255
+ const deliverWebhook = (url: string, payload: unknown) =>
2256
+ Effect.gen(function* () {
2257
+ const ctx = yield* ExecutionContextService
2258
+
2259
+ yield* ctx.runInBackground(
2260
+ Effect.tryPromise(() =>
2261
+ fetch(url, {
2262
+ method: 'POST',
2263
+ headers: { 'Content-Type': 'application/json' },
2264
+ body: JSON.stringify(payload),
2265
+ })
2266
+ ).pipe(
2267
+ Effect.retry({ times: 3 }),
2268
+ Effect.catchAll((error) =>
2269
+ Effect.logError('Webhook delivery failed', { url, error })
2270
+ )
2271
+ )
2272
+ )
2273
+ })
2274
+
2275
+ // Conditional background work
2276
+ const maybeNotifySlack = (message: string) =>
2277
+ Effect.gen(function* () {
2278
+ const ctx = yield* ExecutionContextService
2279
+ const bindings = yield* BindingsService
2280
+
2281
+ // Only run if Slack is configured and background is available
2282
+ if (bindings.SLACK_WEBHOOK_URL && ctx.isAvailable) {
2283
+ yield* ctx.runInBackground(
2284
+ Effect.tryPromise(() =>
2285
+ fetch(bindings.SLACK_WEBHOOK_URL, {
2286
+ method: 'POST',
2287
+ body: JSON.stringify({ text: message }),
2288
+ })
2289
+ )
2290
+ )
2291
+ }
2292
+ })
2293
+ ```
2294
+
2295
+ **Important notes:**
2296
+ - Background tasks run after the response is sent to the user
2297
+ - Errors in background tasks are logged but don't crash the worker
2298
+ - In non-Worker environments (tests, local dev), `isAvailable` is `false` and stale entries are recomputed synchronously
2299
+ - Use `catchAll` to handle errors gracefully in background tasks
2300
+
2301
+ ### Cache Key Versioning
2302
+
2303
+ Cache versioning ensures cache correctness when your data schema changes. Without versioning, schema changes can cause decode errors or serve stale data with the wrong shape.
2304
+
2305
+ **The Problem:**
2306
+
2307
+ ```typescript
2308
+ // Version 1 of your schema
2309
+ const UserSchemaV1 = S.Struct({
2310
+ id: S.String,
2311
+ name: S.String,
2312
+ email: S.String,
2313
+ })
2314
+
2315
+ // You deploy with cached data...
2316
+
2317
+ // Version 2 adds a required field
2318
+ const UserSchemaV2 = S.Struct({
2319
+ id: S.String,
2320
+ name: S.String,
2321
+ email: S.String,
2322
+ avatar: S.String, // New required field!
2323
+ })
2324
+
2325
+ // Cached V1 data fails to decode with V2 schema → Runtime error
2326
+ ```
2327
+
2328
+ **Solution 1: Auto Schema Versioning (Recommended)**
2329
+
2330
+ Pass `version: true` to automatically version cache keys based on a hash of the schema structure. When you change the schema, the hash changes, and old cached data is automatically bypassed.
2331
+
2332
+ ```typescript
2333
+ import { Effect, Duration, Schema as S } from 'effect'
2334
+ import { cache, DatabaseService } from 'honertia/effect'
2335
+
2336
+ const UserSchema = S.Struct({
2337
+ id: S.String,
2338
+ name: S.String,
2339
+ email: S.String,
2340
+ })
2341
+
2342
+ // Cache key becomes "a1b2c3:user:123" (hash:key)
2343
+ const user = yield* cache(
2344
+ `user:${id}`,
2345
+ fetchUser(id),
2346
+ UserSchema,
2347
+ { ttl: Duration.hours(1), version: true }
2348
+ )
2349
+ ```
2350
+
2351
+ **When to use `version: true`:**
2352
+ - You want automatic cache invalidation when schemas evolve
2353
+ - You're iterating quickly on data structures during development
2354
+ - You want zero-downtime deployments without manual cache clearing
2355
+
2356
+ **When NOT to use `version: true`:**
2357
+ - You need cache hits to survive deployments (use explicit versions instead)
2358
+ - Schema changes are intentionally backward-compatible
2359
+ - You're caching data that doesn't depend on schema structure
2360
+
2361
+ **Solution 2: Explicit Version Strings**
2362
+
2363
+ For more control, pass an explicit version string. Bump it manually when your schema changes.
2364
+
2365
+ ```typescript
2366
+ // Cache key becomes "v2:user:123"
2367
+ const user = yield* cache(
2368
+ `user:${id}`,
2369
+ fetchUser(id),
2370
+ UserSchema,
2371
+ { ttl: Duration.hours(1), version: 'v2' }
2372
+ )
2373
+ ```
2374
+
2375
+ **When to use explicit versions:**
2376
+ - You want cache hits to survive deployments
2377
+ - You need predictable cache keys for debugging
2378
+ - You're coordinating schema changes across services
2379
+
2380
+ **Full Example: User Profile with Versioned Cache**
2381
+
2382
+ ```typescript
2383
+ import { Effect, Duration, Schema as S } from 'effect'
2384
+ import {
2385
+ action,
2386
+ authorize,
2387
+ cache,
2388
+ cacheInvalidate,
2389
+ asTrusted,
2390
+ render,
2391
+ redirect,
2392
+ DatabaseService,
2393
+ validateRequest,
2394
+ dbMutation,
2395
+ } from 'honertia/effect'
2396
+ import { eq } from 'drizzle-orm'
2397
+ import { users } from '~/db/schema'
2398
+
2399
+ // Schema definition - changing this auto-invalidates cache when version: true
2400
+ const UserProfileSchema = S.Struct({
2401
+ id: S.String,
2402
+ name: S.String,
2403
+ email: S.String,
2404
+ bio: S.NullOr(S.String),
2405
+ avatarUrl: S.NullOr(S.String),
2406
+ })
2407
+
2408
+ // GET /profile - cached with auto-versioning
2409
+ export const showProfile = action(
2410
+ Effect.gen(function* () {
2411
+ const auth = yield* authorize()
2412
+ const db = yield* DatabaseService
2413
+
2414
+ const profile = yield* cache(
2415
+ `user:profile:${auth.user.id}`,
2416
+ Effect.tryPromise(() =>
2417
+ db.query.users.findFirst({
2418
+ where: eq(users.id, auth.user.id),
2419
+ columns: { id: true, name: true, email: true, bio: true, avatarUrl: true },
2420
+ })
2421
+ ),
2422
+ UserProfileSchema,
2423
+ {
2424
+ ttl: Duration.hours(1),
2425
+ swr: Duration.minutes(5),
2426
+ version: true, // Auto-invalidates when UserProfileSchema changes
2427
+ }
2428
+ )
2429
+
2430
+ return yield* render('Profile/Show', { profile })
2431
+ })
2432
+ )
2433
+
2434
+ // PUT /profile - invalidate cache after update
2435
+ export const updateProfile = action(
2436
+ Effect.gen(function* () {
2437
+ const auth = yield* authorize()
2438
+ const input = yield* validateRequest(UpdateProfileSchema, {
2439
+ errorComponent: 'Profile/Edit',
2440
+ })
2441
+ const db = yield* DatabaseService
2442
+
2443
+ yield* dbMutation(db, async (db) => {
2444
+ await db.update(users)
2445
+ .set(asTrusted({ name: input.name, bio: input.bio }))
2446
+ .where(eq(users.id, auth.user.id))
2447
+ })
2448
+
2449
+ // Invalidate with same versioning strategy
2450
+ yield* cacheInvalidate(`user:profile:${auth.user.id}`, {
2451
+ schema: UserProfileSchema,
2452
+ version: true,
2453
+ })
2454
+
2455
+ return yield* redirect('/profile')
2456
+ })
2457
+ )
2458
+ ```
2459
+
2460
+ **Versioning with `cacheGet` and `cacheInvalidate`:**
2461
+
2462
+ When using manual cache operations, pass the same version option:
2463
+
2464
+ ```typescript
2465
+ import { cacheGet, cacheSet, cacheInvalidate } from 'honertia/effect'
2466
+
2467
+ // Get with auto-versioning
2468
+ const cached = yield* cacheGet(`user:${id}`, UserSchema, { version: true })
2469
+
2470
+ // Set with explicit version
2471
+ yield* cacheSet(`user:${id}`, user, UserSchema, {
2472
+ ttl: Duration.hours(1),
2473
+ version: 'v2',
2474
+ })
2475
+
2476
+ // Invalidate with versioning - requires schema when using version
2477
+ yield* cacheInvalidate(`user:${id}`, { schema: UserSchema, version: true })
2478
+
2479
+ // Simple invalidation (no versioning)
2480
+ yield* cacheInvalidate(`user:${id}`)
2481
+ ```
2482
+
2483
+ **How Schema Hashing Works:**
2484
+
2485
+ The auto-versioning feature uses the djb2 hash algorithm on the serialized schema AST:
2486
+
2487
+ 1. Schema structure is serialized to a string representation
2488
+ 2. A fast, deterministic hash is computed (djb2)
2489
+ 3. The hash is prepended to the cache key
2490
+
2491
+ This means:
2492
+ - Same schema definition → same hash → cache hits work
2493
+ - Changed schema structure → different hash → cache miss, fresh data computed
2494
+ - Adding/removing fields, changing types, or modifying constraints all change the hash
2495
+
2496
+ **Cache Options Reference:**
2497
+
2498
+ | Option | Type | Description |
2499
+ |--------|------|-------------|
2500
+ | `ttl` | `Duration.DurationInput` | Time-to-live for cached values (required) |
2501
+ | `swr` | `Duration.DurationInput` | Stale-while-revalidate window (optional) |
2502
+ | `version` | `string \| boolean` | `true` = auto schema hash, `string` = explicit prefix (optional) |
2503
+
1982
2504
  ---
1983
2505
 
1984
2506
  ## Services Reference
@@ -1990,6 +2512,7 @@ Recommended cache key patterns:
1990
2512
  | `AuthUserService` | Current user session | `const user = yield* AuthUserService` |
1991
2513
  | `BindingsService` | Cloudflare bindings | `const { KV } = yield* BindingsService` |
1992
2514
  | `CacheService` | KV-backed cache client | `const cache = yield* CacheService` |
2515
+ | `ExecutionContextService` | Background task execution | `const ctx = yield* ExecutionContextService` |
1993
2516
  | `RequestService` | Request context | `const req = yield* RequestService` |
1994
2517
 
1995
2518
  ### Using BindingsService
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=__tmp_db_safety_probe.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"__tmp_db_safety_probe.d.ts","sourceRoot":"","sources":["../src/__tmp_db_safety_probe.ts"],"names":[],"mappings":""}
@@ -0,0 +1,13 @@
1
+ import { asValidated } from './effect/validation.js';
2
+ async function probe() {
3
+ const raw = { name: 'Pat', email: 'pat@example.com' };
4
+ // @ts-expect-error raw input should be rejected by SafeTx
5
+ await db.insert('users').values(raw);
6
+ const validated = asValidated(raw);
7
+ await db.insert('users').values(validated);
8
+ const merged = { ...validated, email: validated.email.toLowerCase() };
9
+ await db.insert('users').values(merged);
10
+ const role = (Math.random() > 0.5 ? 'admin' : 'member');
11
+ const mergedWithUnvalidated = { ...validated, role };
12
+ await dbWithRole.insert('users').values(mergedWithUnvalidated);
13
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=__tmp_scoped_astrusted_probe.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"__tmp_scoped_astrusted_probe.d.ts","sourceRoot":"","sources":["../src/__tmp_scoped_astrusted_probe.ts"],"names":[],"mappings":""}
@@ -0,0 +1,10 @@
1
+ import { Effect } from 'effect';
2
+ import { dbMutation } from './effect/action.js';
3
+ import { asTrusted } from './effect/validation.js';
4
+ const txInput = asTrusted({ createUser: { name: 'pat' } });
5
+ const program = dbMutation(db, txInput, async (tx, scoped) => {
6
+ await tx.insert('users').values(scoped.createUser);
7
+ // @ts-expect-error scoped mode rejects ad-hoc asTrusted payloads
8
+ await tx.insert('users').values(asTrusted({ name: 'other' }));
9
+ });
10
+ void Effect.runSync(program);
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=__tmp_scoped_derived_probe.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"__tmp_scoped_derived_probe.d.ts","sourceRoot":"","sources":["../src/__tmp_scoped_derived_probe.ts"],"names":[],"mappings":""}