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.
- package/README.md +536 -13
- package/dist/__tmp_db_safety_probe.d.ts +2 -0
- package/dist/__tmp_db_safety_probe.d.ts.map +1 -0
- package/dist/__tmp_db_safety_probe.js +13 -0
- package/dist/__tmp_scoped_astrusted_probe.d.ts +2 -0
- package/dist/__tmp_scoped_astrusted_probe.d.ts.map +1 -0
- package/dist/__tmp_scoped_astrusted_probe.js +10 -0
- package/dist/__tmp_scoped_derived_probe.d.ts +2 -0
- package/dist/__tmp_scoped_derived_probe.d.ts.map +1 -0
- package/dist/__tmp_scoped_derived_probe.js +12 -0
- package/dist/__tmp_scoped_mutation_probe.d.ts +2 -0
- package/dist/__tmp_scoped_mutation_probe.d.ts.map +1 -0
- package/dist/__tmp_scoped_mutation_probe.js +15 -0
- package/dist/cache.d.ts +78 -7
- package/dist/cache.d.ts.map +1 -1
- package/dist/cache.js +152 -23
- package/dist/cli/bin.d.ts +8 -0
- package/dist/cli/bin.d.ts.map +1 -0
- package/dist/cli/bin.js +150 -0
- package/dist/cli/db.d.ts.map +1 -1
- package/dist/cli/db.js +83 -18
- package/dist/cli/feature.d.ts +1 -0
- package/dist/cli/feature.d.ts.map +1 -1
- package/dist/cli/feature.js +76 -11
- package/dist/cli/generate.d.ts.map +1 -1
- package/dist/cli/generate.js +124 -22
- package/dist/cli/index.d.ts +6 -2
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +16 -4
- package/dist/cli/inline-tests.d.ts.map +1 -1
- package/dist/cli/inline-tests.js +4 -0
- package/dist/cli/openapi.d.ts +2 -1
- package/dist/cli/openapi.d.ts.map +1 -1
- package/dist/cli/openapi.js +68 -3
- package/dist/effect/action.d.ts +92 -3
- package/dist/effect/action.d.ts.map +1 -1
- package/dist/effect/action.js +38 -32
- package/dist/effect/auth.d.ts +3 -1
- package/dist/effect/auth.d.ts.map +1 -1
- package/dist/effect/auth.js +17 -3
- package/dist/effect/bridge.d.ts +7 -2
- package/dist/effect/bridge.d.ts.map +1 -1
- package/dist/effect/bridge.js +48 -4
- package/dist/effect/index.d.ts +4 -4
- package/dist/effect/index.d.ts.map +1 -1
- package/dist/effect/index.js +3 -3
- package/dist/effect/routing.d.ts.map +1 -1
- package/dist/effect/routing.js +14 -14
- package/dist/effect/services.d.ts +67 -0
- package/dist/effect/services.d.ts.map +1 -1
- package/dist/effect/services.js +20 -0
- package/dist/effect/validation.d.ts +10 -1
- package/dist/effect/validation.d.ts.map +1 -1
- package/dist/effect/validation.js +49 -14
- package/dist/schema.d.ts +1 -1
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +1 -1
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +3 -2
- 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
|
|
121
|
-
|
|
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,
|
|
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,
|
|
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
|
|
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 {
|
|
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 @@
|
|
|
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 @@
|
|
|
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 @@
|
|
|
1
|
+
{"version":3,"file":"__tmp_scoped_derived_probe.d.ts","sourceRoot":"","sources":["../src/__tmp_scoped_derived_probe.ts"],"names":[],"mappings":""}
|