Skip to content

Commit

Permalink
Merge branch 'feat/dc-kc-decoupled-auth-setup-improvements' of github…
Browse files Browse the repository at this point in the history
….com:redwoodjs/redwood into feat/dc-kc-decoupled-auth-setup-improvements

* 'feat/dc-kc-decoupled-auth-setup-improvements' of github.com:redwoodjs/redwood:
  chore(deps): update dependency nx to v15.3.2 (#7114)
  chore(deps): update dependency redis to v4.5.1 (#7115)
  fix: add missing deps to cli helpers (#7117)
  Adds ability to delete a cache entry (#7016)
  Label override flags for dbAuth generator (#6440)
  fix(deps): update dependency systeminformation to v5.16.6 (#7108)
  feat: Generator rollbacks  (#6947)
  fix(deps): update dependency @types/node to v16.18.8 (#7107)
  chore(deps): update dependency supertokens-node to v12.1.3 (#7105)
  • Loading branch information
dac09 committed Dec 13, 2022
2 parents 50ec8b9 + 2329746 commit 740a93b
Show file tree
Hide file tree
Showing 46 changed files with 6,113 additions and 202 deletions.
Expand Up @@ -68,7 +68,10 @@ const ForgotPasswordPage = () => {
errorClassName="rw-input rw-input-error"
ref={usernameRef}
validation={{
required: true,
required: {
value: true,
message: 'Username is required',
},
}}
/>

Expand Down
Expand Up @@ -92,7 +92,7 @@ const ResetPasswordPage = ({ resetToken }: { resetToken: string }) => {
validation={{
required: {
value: true,
message: 'Password is required',
message: 'New Password is required',
},
}}
/>
Expand Down
Expand Up @@ -24,7 +24,7 @@ const SignupPage = () => {
}
}, [isAuthenticated])

// focus on email box on page load
// focus on username box on page load
const usernameRef = useRef<HTMLInputElement>(null)
useEffect(() => {
usernameRef.current?.focus()
Expand Down
29 changes: 22 additions & 7 deletions docs/docs/cli-commands.md
Expand Up @@ -453,6 +453,7 @@ Cells are signature to Redwood. We think they provide a simpler and more declara
| `--list` | Use this flag to generate a list cell. This flag is needed when dealing with irregular words whose plural and singular is identical such as equipment or pokemon |
| `--tests` | Generate test files [default: true] |
| `--stories` | Generate Storybook files [default: true] |
| `--rollback` | Rollback changes if an error occurs [default: true] |
**Usage**
Expand Down Expand Up @@ -519,6 +520,7 @@ Redwood loves function components and makes extensive use of React Hooks, which
| `--typescript, --ts` | Generate TypeScript files Enabled by default if we detect your project is TypeScript |
| `--tests` | Generate test files [default: true] |
| `--stories` | Generate Storybook files [default: true] |
| `--rollback` | Rollback changes if an error occurs [default: true] |
**Destroying**
Expand Down Expand Up @@ -568,6 +570,7 @@ Creates a data migration script in `api/db/dataMigrations`.
| Arguments & Options | Description |
| :------------------ | :----------------------------------------------------------------------- |
| `name` | Name of the data migration, prefixed with a timestamp at generation time |
| `--rollback` | Rollback changes if an error occurs [default: true] |
**Usage**
Expand All @@ -585,9 +588,12 @@ Generate log in, sign up, forgot password and password reset pages for dbAuth
yarn redwood generate dbAuth
```
| Arguments & Options | Description |
| ------------------- | ------------------------------------------------------------------------------------------------ |
| `--webAuthn` | Whether or not to add webAuthn support to the log in page. If not specified you will be prompted |
| Arguments & Options | Description |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `--username-label` | The label to give the username field on the auth forms, e.g. "Email". Defaults to "Username". If not specified you will be prompted |
| `--password-label` | The label to give the password field on the auth forms, e.g. "Secret". Defaults to "Password". If not specified you will be prompted |
| `--webAuthn` | Whether or not to add webAuthn support to the log in page. If not specified you will be prompted |
| `--rollback` | Rollback changes if an error occurs [default: true]
If you don't want to create your own log in, sign up, forgot password and
password reset pages from scratch you can use this generator. The pages will be
Expand All @@ -613,6 +619,7 @@ yarn redwood generate directive <name>
| `--force, -f` | Overwrite existing files |
| `--typescript, --ts` | Generate TypeScript files (defaults to your projects language target) |
| `--type` | Directive type [Choices: "validator", "transformer"] |
| `--rollback` | Rollback changes if an error occurs [default: true] |
**Usage**
Expand Down Expand Up @@ -651,6 +658,7 @@ Not to be confused with Javascript functions, Capital-F Functions are meant to b
| `name` | Name of the function |
| `--force, -f` | Overwrite existing files |
| `--typescript, --ts` | Generate TypeScript files Enabled by default if we detect your project is TypeScript |
| `--rollback` | Rollback changes if an error occurs [default: true] |
**Usage**
Expand Down Expand Up @@ -720,6 +728,7 @@ Layouts wrap pages and help you stay DRY.
| `--tests` | Generate test files [default: true] |
| `--stories` | Generate Storybook files [default: true] |
| `--skipLink` | Generate a layout with a skip link [default: false] |
| `--rollback` | Rollback changes if an error occurs [default: true] |
**Usage**
Expand Down Expand Up @@ -763,10 +772,11 @@ Generate a RedwoodRecord model.
yarn redwood generate model <name>
```
| Arguments & Options | Description |
| ------------------- | ------------------------------------ |
| `name` | Name of the model (in schema.prisma) |
| `--force, -f` | Overwrite existing files |
| Arguments & Options | Description |
| ------------------- | ----------------------------------------------------- |
| `name` | Name of the model (in schema.prisma) |
| `--force, -f` | Overwrite existing files |
| `--rollback` | Rollback changes if an error occurs [default: true] |
**Usage**
Expand Down Expand Up @@ -814,6 +824,7 @@ This also updates `Routes.js` in `./web/src`.
| `--typescript, --ts` | Generate TypeScript files Enabled by default if we detect your project is TypeScript |
| `--tests` | Generate test files [default: true] |
| `--stories` | Generate Storybook files [default: true] |
| `--rollback` | Rollback changes if an error occurs [default: true] |
**Destroying**
Expand Down Expand Up @@ -939,6 +950,7 @@ The content of the generated components is different from what you'd get by runn
| `--force, -f` | Overwrite existing files |
| `--tailwind` | Generate TailwindCSS version of scaffold.css (automatically set to `true` if TailwindCSS config exists) |
| `--typescript, --ts` | Generate TypeScript files Enabled by default if we detect your project is TypeScript |
| `--rollback` | Rollback changes if an error occurs [default: true] |
**Usage**
Expand Down Expand Up @@ -1075,6 +1087,7 @@ Generates an arbitrary Node.js script in `./scripts/<name>` that can be used wit
| -------------------- | ------------------------------------------------------------------------------------ |
| `name` | Name of the service |
| `--typescript, --ts` | Generate TypeScript files Enabled by default if we detect your project is TypeScript |
| `--rollback` | Rollback changes if an error occurs [default: true] |
Scripts have access to services and libraries used in your project. Some examples of how this can be useful:
Expand Down Expand Up @@ -1120,6 +1133,7 @@ https://community.redwoodjs.com/t/prisma-beta-2-and-redwoodjs-limited-generator-
| `--force, -f` | Overwrite existing files |
| `--tests` | Generate service test and scenario [default: true] |
| `--typescript, --ts` | Generate TypeScript files Enabled by default if we detect your project is TypeScript |
| `--rollback` | Rollback changes if an error occurs [default: true] |
> **Note:** The generated sdl will include the `@requireAuth` directive by default to ensure queries and mutations are secure. If your app's queries and mutations are all public, you can set up a custom SDL generator template to apply `@skipAuth` (or a custom validator directive) to suit you application's needs.
Expand Down Expand Up @@ -1303,6 +1317,7 @@ Services are where Redwood puts its business logic. They can be used by your Gra
| `--force, -f` | Overwrite existing files |
| `--typescript, --ts` | Generate TypeScript files Enabled by default if we detect your project is TypeScript |
| `--tests` | Generate test and scenario files [default: true] |
| `--rollback` | Rollback changes if an error occurs [default: true] |
**Destroying**
Expand Down
53 changes: 52 additions & 1 deletion docs/docs/services.md
Expand Up @@ -805,6 +805,30 @@ An easier solution to this problem would be to add some kind of version number t
And this key is our final form: a unique, but flexible key that allows us to expire the cache on demand (change the version) or automatically expire it when the record itself changes.
:::info
One more case: what if the underlying `Product` model itself changes, adding a new field, for example? Each product will now have new data, but no changes will occur to `updatedAt` as a result of adding this column. There are a couple things you could do here:
* Increment the version on the key, if you have one: `v1` => `v2`
* "Touch" all of the Product records in a script, forcing them to have their `updatedAt` timestamp changed
* Incorporate a hash of all the keys of a `product` into the cache key
How does that last one work? We get a list of all the keys and then apply a hashing algorithm like MD5 to get a string that's unique based on that list of database columns. Then if one is ever added or removed, the hash will change, which will change the key, which will bust the cache:
```javascript
const product = db.product.findUnique({ where: { id } })
const columns = Object.keys(product) // ['id', 'name', 'sku', ...]
const hash = md5(columns.join(',')) // "e4d7f1b4ed2e42d15898f4b27b019da4"

cache(`v1-product-${hash}-${id}-${updatedAt}`, () => {
// ...
})
```
Note that this has the side effect of having to select at least one record from the database so that you know what the column names are, but presumably this is much less overhead that whatever computation you're trying to avoid by caching: the slow work that happens inside of the function passed to `cache()` will still be avoided on subsequent calls (and selecting a single record from the database by an indexed column like `id` should be very fast).
:::
#### Expiration-based Keys
You can skirt these issues about what data is changing and what to include or not include in the key by just setting an expiration time on this cache entry. You may decide that if a change is made to a product, it's okay if users don't see the change for, say, an hour. In this case just set the expiration time to 3600 seconds and it will automatically be re-built, whether something changed in the record or not:
Expand Down Expand Up @@ -844,7 +868,7 @@ cache(`recommended-${context.currentUser.id}`, () => {
})
```
If every page the user visits has a different list of recommended products then creating this cache may not be worth it: how often does the user revisit the same product page more than once? Conversely, if you show the *same* recommended products on every page then this cache would definitely improve the user's experience.
If every page the user visits has a different list of recommended products for every page (meaning that the full computation will need to run at least once, before it's cached) then creating this cache may not be worth it: how often does the user revisit the same product page more than once? Conversely, if you show the *same* recommended products on every page then this cache would definitely improve the user's experience.
The *key* to writing a good key (!) is to think carefully about the circumstances in which the key needs to expire, and include those bits of information into the key string/array. Adding caching can lead to weird bugs you don't expect, but in these cases the root cause will usually be the cache key not containing enough bits of information to expire it correctly. When in doubt, restart the app with the cache server (memcached or redis) disabled and see if the same behavior is still present. If not, the cache key is the culprit!
Expand Down Expand Up @@ -1016,6 +1040,33 @@ const post = ({ id }) => {
:::
### `deleteCacheKey()`
There may be instances where you want to explictly remove something from the cache so that it gets re-created with the same cache key. A good example is caching a single user, using only their `id` as the cache key. By default, the cache would never bust because a user's `id` is not going to change, no matter how many other fields on user are updated. With `deleteCacheKey()` you can choose to delete the key, for example, when the `updateUser()` service is called. The next time `user()` is called, it will be re-cached with the same key, but it will now contain whatever data was updated.
```javascript
import { cache, deleteCacheKey } from 'src/lib/cache'

const user = ({ id }) => {
return cache(`user-${id}`, () => {
return db.user.findUnique({ where: { id } })
})
})

const updateUser = async ({ id, input }) => {
await deleteCacheKey(`user-${id}`)
return db.user.update({ where: { id }, data: { input } })
})
```
:::caution
When explictly deleting cache keys like this you could find yourself going down a rabbit hole. What if there is another service somewhere that also updates user? Or another service that updates an organization, as well as all of its underlying child users at the same time? You'll need to be sure to call `deleteCacheKey()` in these places as well. As a general guideline, it's better to come up with a cache key that encapsulates any triggers for when the data has changed (like the `updatedAt` timestamp, which will change no matter who updates the user, anywhere in your codebase).
Scenarios like this are what people are talking about when they say that caching is hard!
:::
### Testing what you cache
We wouldn't just give you all of these caching APIs and not show you how to test it right? You'll find all the details in the [Caching section in the testing doc](testing.md#testing-caching).
Expand Down
2 changes: 1 addition & 1 deletion package.json
Expand Up @@ -95,7 +95,7 @@
"node-notifier": "10.0.1",
"nodemon": "2.0.20",
"npm-packlist": "5.1.3",
"nx": "15.3.0",
"nx": "15.3.2",
"octokit": "2.0.10",
"ora": "5.4.1",
"prompts": "2.4.2",
Expand Down
2 changes: 1 addition & 1 deletion packages/api/package.json
Expand Up @@ -63,7 +63,7 @@
"aws-lambda": "1.0.7",
"jest": "29.3.1",
"memjs": "1.3.0",
"redis": "4.2.0",
"redis": "4.5.1",
"split2": "4.1.0",
"typescript": "4.7.4"
},
Expand Down
37 changes: 37 additions & 0 deletions packages/api/src/cache/__tests__/deleteCacheKey.test.js
@@ -0,0 +1,37 @@
import InMemoryClient from '../clients/InMemoryClient'
import { createCache } from '../index'

describe('deleteCacheKey', () => {
it('deletes a key from the cache', async () => {
const client = new InMemoryClient({
test: { expires: 1977175194415, value: '{"foo":"bar"}' },
})
const { deleteCacheKey } = createCache(client)

await deleteCacheKey('test')

expect(client.storage['test']).toEqual(undefined)
})

it('returns true if key was deleted', async () => {
const client = new InMemoryClient({
test: { expires: 1977175194415, value: '{"foo":"bar"}' },
})
const { deleteCacheKey } = createCache(client)

const result = await deleteCacheKey('test')

expect(result).toEqual(true)
})

it('returns false if key did not exist', async () => {
const client = new InMemoryClient({
test: { expires: 1977175194415, value: '{"foo":"bar"}' },
})
const { deleteCacheKey } = createCache(client)

const result = await deleteCacheKey('foobar')

expect(result).toEqual(false)
})
})
3 changes: 3 additions & 0 deletions packages/api/src/cache/clients/BaseClient.ts
Expand Up @@ -16,4 +16,7 @@ export default abstract class BaseClient {
value: unknown,
options: { expires?: number }
): Promise<any> | any // types are tightened in the child classes

// Removes a value by its key
abstract del(key: string): Promise<boolean> | any
}
9 changes: 9 additions & 0 deletions packages/api/src/cache/clients/InMemoryClient.ts
Expand Up @@ -52,6 +52,15 @@ export default class InMemoryClient extends BaseClient {
return true
}

async del(key: string) {
if (this.storage[key]) {
delete this.storage[key]
return true
} else {
return false
}
}

/**
* Special functions for testing, only available in InMemoryClient
*/
Expand Down
9 changes: 9 additions & 0 deletions packages/api/src/cache/clients/MemcachedClient.ts
Expand Up @@ -44,4 +44,13 @@ export default class MemcachedClient extends BaseClient {

return this.client?.set(key, JSON.stringify(value), options)
}

async del(key: string) {
if (!this.client) {
await this.connect()
}

// memcached returns true/false natively
return this.client?.delete(key)
}
}
9 changes: 9 additions & 0 deletions packages/api/src/cache/clients/RedisClient.ts
Expand Up @@ -66,4 +66,13 @@ export default class RedisClient extends BaseClient {

return this.client?.set(key, JSON.stringify(value), setOptions)
}

async del(key: string) {
if (!this.client) {
await this.connect()
}

// Redis client returns 0 or 1, so convert to true/false manually
return !!(await this.client?.del([key]))
}
}
20 changes: 20 additions & 0 deletions packages/api/src/cache/index.ts
Expand Up @@ -185,8 +185,28 @@ export const createCache = (
return cache(latestCacheKey, () => model.findMany(conditions), rest)
}

const deleteCacheKey = async (key: CacheKey) => {
let result

try {
await Promise.race([
(result = client.del(key as string)),
wait(timeout).then(() => {
throw new CacheTimeoutError()
}),
])

logger?.debug(`[Cache] DEL '${key}'`)
return result
} catch (e: any) {
logger?.error(`[Cache] Error DEL '${key}': ${e.message}`)
return false
}
}

return {
cache,
cacheFindMany,
deleteCacheKey,
}
}
2 changes: 1 addition & 1 deletion packages/auth-providers/supertokens/api/package.json
Expand Up @@ -36,7 +36,7 @@
"typescript": "4.7.4"
},
"peerDependencies": {
"supertokens-node": "12.1.1"
"supertokens-node": "12.1.3"
},
"gitHead": "3905ed045508b861b495f8d5630d76c7a157d8f1"
}

0 comments on commit 740a93b

Please sign in to comment.