@owlmeans/redis 0.1.17 → 0.1.18-rc.0

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.
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
3
  "package": "@owlmeans/redis",
4
- "version": "0.1.17",
5
- "generatedAt": "2026-08-15T13:01:19.942Z",
4
+ "version": "0.1.18-rc.0",
5
+ "generatedAt": "2026-08-16T22:20:50.516Z",
6
6
  "canonicalRepo": "https://github.com/owlmeans/common",
7
7
  "entries": [
8
8
  {
@@ -8,7 +8,7 @@ user-invocable: false
8
8
  # @owlmeans/redis
9
9
 
10
10
  **Layer:** Infra
11
- **Install:** `"@owlmeans/redis": "^0.1.17"` in `dependencies`
11
+ **Install:** `"@owlmeans/redis": "^0.1.18-rc.0"` in `dependencies`
12
12
 
13
13
  ## Key Exports
14
14
 
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@owlmeans/redis",
3
- "version": "0.1.17",
3
+ "version": "0.1.18-rc.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "build": "tsc -b",
8
8
  "dev": "sleep 240 && nodemon -e ts,tsx,json --watch src --exec \"tsc -p ./tsconfig.json\"",
9
- "watch": "tsc -b -w --preserveWatchOutput --pretty"
9
+ "watch": "tsc -b -w --preserveWatchOutput --pretty",
10
+ "test": "bun test ./tests"
10
11
  },
11
12
  "main": "build/index.js",
12
13
  "module": "build/index.js",
@@ -22,16 +23,18 @@
22
23
  },
23
24
  "devDependencies": {
24
25
  "@owlmeans/dep-config": "workspace:*",
26
+ "@owlmeans/test-integration": "^0.1.18-rc.0",
27
+ "@types/bun": "^1.3.14",
25
28
  "@types/node": "^26.1.0",
26
29
  "nodemon": "^3.1.14",
27
30
  "typescript": "^7.0.2"
28
31
  },
29
32
  "dependencies": {
30
33
  "@noble/hashes": "^1.5.0",
31
- "@owlmeans/context": "^0.1.17",
32
- "@owlmeans/redis-resource": "^0.1.17",
33
- "@owlmeans/resource": "^0.1.17",
34
- "@owlmeans/server-context": "^0.1.17",
34
+ "@owlmeans/context": "^0.1.18-rc.0",
35
+ "@owlmeans/redis-resource": "^0.1.18-rc.0",
36
+ "@owlmeans/resource": "^0.1.18-rc.0",
37
+ "@owlmeans/server-context": "^0.1.18-rc.0",
35
38
  "@scure/base": "^2.3.0",
36
39
  "ioredis": "^5.4.1"
37
40
  },
@@ -0,0 +1,84 @@
1
+ import { redisGate, randomNamespace } from '@owlmeans/test-integration'
2
+ import type { IntegrationGate, RedisEnv } from '@owlmeans/test-integration'
3
+ import { config, makeServerContext } from '@owlmeans/server-context'
4
+ import type { ServerConfig, ServerContext } from '@owlmeans/server-context'
5
+ import type { ResourceRecord } from '@owlmeans/resource'
6
+ import { makeRedisResource } from '@owlmeans/redis-resource'
7
+ import type { RedisResource } from '@owlmeans/redis-resource'
8
+ import { appendRedis, DEFAULT_ALIAS } from '@owlmeans/redis'
9
+
10
+ export const gate: IntegrationGate<RedisEnv> = redisGate()
11
+
12
+ export interface TestRecord extends ResourceRecord {
13
+ id: string
14
+ value?: string
15
+ }
16
+
17
+ export interface RedisSuite {
18
+ /** Key prefix this suite owns end to end. Flushed by {@link teardown}. */
19
+ prefix: string
20
+ boot: (alias?: string) => Promise<{
21
+ context: ServerContext<ServerConfig>
22
+ resource: RedisResource<TestRecord>
23
+ }>
24
+ teardown: () => Promise<void>
25
+ }
26
+
27
+ /**
28
+ * One key prefix per suite, dropped by that suite's own `afterAll` — Bun runs every spec
29
+ * file of a package in one process, so a process-global cleanup queue would let the first
30
+ * file to finish flush the keys of the files still to come.
31
+ */
32
+ export const makeSuite = (label: string): RedisSuite => {
33
+ const base = process.env.REDIS_TEST_KEY_PREFIX ?? 'omt'
34
+ const prefix = randomNamespace(`${base}_${label}`)
35
+ const contexts: Array<ServerContext<ServerConfig>> = []
36
+
37
+ const boot = async (alias = 'test-records'): Promise<{
38
+ context: ServerContext<ServerConfig>
39
+ resource: RedisResource<TestRecord>
40
+ }> => {
41
+ const url = new URL(gate.env.REDIS_URL as string)
42
+ const cfg: ServerConfig = config('redis-test', {
43
+ dbs: [{
44
+ service: DEFAULT_ALIAS,
45
+ alias: DEFAULT_ALIAS,
46
+ host: url.hostname,
47
+ port: url.port !== '' ? Number(url.port) : 6379,
48
+ user: url.username !== '' ? url.username : undefined,
49
+ secret: decodeURIComponent(url.password),
50
+ schema: prefix,
51
+ }]
52
+ } as Partial<ServerConfig>)
53
+
54
+ const context = makeServerContext(cfg) as ServerContext<ServerConfig>
55
+ appendRedis(context)
56
+ const resource = makeRedisResource<TestRecord>(alias)
57
+ context.registerResource(resource as never)
58
+
59
+ context.configure()
60
+ await context.init()
61
+ contexts.push(context)
62
+
63
+ return { context, resource: context.resource<RedisResource<TestRecord>>(alias) }
64
+ }
65
+
66
+ const teardown = async (): Promise<void> => {
67
+ if (gate.skip) {
68
+ return
69
+ }
70
+ for (const context of contexts) {
71
+ const resource = context.resource<RedisResource<TestRecord>>('test-records')
72
+ const client = resource.db?.client
73
+ if (client == null) continue
74
+ const keys = await client.keys(`${prefix}:*`).catch(() => [] as string[])
75
+ if (keys.length > 0) {
76
+ await client.del(...keys).catch(() => undefined)
77
+ }
78
+ await client.quit().catch(() => undefined)
79
+ }
80
+ contexts.length = 0
81
+ }
82
+
83
+ return { prefix, boot, teardown }
84
+ }
@@ -0,0 +1,56 @@
1
+ import { afterAll, describe, expect, test } from 'bun:test'
2
+ import { gate, makeSuite } from './context.js'
3
+
4
+ /**
5
+ * `delete` regression coverage.
6
+ *
7
+ * The by-id form silently did nothing: `delete(id, opts)` only loaded the record when `id`
8
+ * was an object or `opts` was a field name, so the plain `delete(id)` every caller uses fell
9
+ * through to `record == null` and returned before touching redis. It surfaced as
10
+ * `RecordExists` on the second email-OTP request for one address inside the code's TTL —
11
+ * `issueChallenge` upserts by deleting first — and left consumed codes alive in the store.
12
+ */
13
+ describe('@owlmeans/redis — resource delete', () => {
14
+ if (gate.skip) {
15
+ test.skip(gate.reason ?? 'redis gate closed', () => { })
16
+ return
17
+ }
18
+
19
+ const suite = makeSuite('delete')
20
+
21
+ afterAll(async () => {
22
+ await suite.teardown()
23
+ })
24
+
25
+ test('removes the record when called by id alone', async () => {
26
+ const { resource } = await suite.boot()
27
+ await resource.create({ id: 'by-id', value: 'first' })
28
+
29
+ expect(await resource.delete('by-id')).toMatchObject({ id: 'by-id' })
30
+ expect(await resource.load('by-id')).toBeNull()
31
+ })
32
+
33
+ test('returns null for an id that is not there', async () => {
34
+ const { resource } = await suite.boot()
35
+
36
+ expect(await resource.delete('never-created')).toBeNull()
37
+ })
38
+
39
+ test('lets delete-then-create re-issue under the same id — the OTP upsert', async () => {
40
+ const { resource } = await suite.boot()
41
+ await resource.create({ id: 'otp', value: 'first-code' }, { ttl: 600 })
42
+
43
+ await resource.delete('otp')
44
+ await resource.create({ id: 'otp', value: 'second-code' }, { ttl: 600 })
45
+
46
+ expect((await resource.load('otp'))?.value).toBe('second-code')
47
+ })
48
+
49
+ test('removes the record when handed the record itself', async () => {
50
+ const { resource } = await suite.boot()
51
+ const record = await resource.create({ id: 'by-record', value: 'x' })
52
+
53
+ expect(await resource.delete(record)).toMatchObject({ id: 'by-record' })
54
+ expect(await resource.load('by-record')).toBeNull()
55
+ })
56
+ })
@@ -0,0 +1,72 @@
1
+ import { afterAll, describe, expect, test } from 'bun:test'
2
+ import { gate, makeSuite } from './context.js'
3
+
4
+ /**
5
+ * TTL regression coverage.
6
+ *
7
+ * The absolute (`Date`) form was sent to EXPIREAT as `Date.getTime()` — milliseconds against a
8
+ * command that reads seconds — so the expiry landed ~50 millennia out and the record never
9
+ * expired. Asserting the redis-side TTL rather than the call is the whole point here: the buggy
10
+ * version still "set an expiry", just not one that would ever arrive.
11
+ */
12
+ describe('@owlmeans/redis — resource ttl', () => {
13
+ if (gate.skip) {
14
+ test.skip(gate.reason ?? 'redis gate closed', () => { })
15
+ return
16
+ }
17
+
18
+ const suite = makeSuite('ttl')
19
+ /** Generous: the assertion is about the order of magnitude, not clock precision. */
20
+ const tolerance = 5_000
21
+
22
+ afterAll(async () => {
23
+ await suite.teardown()
24
+ })
25
+
26
+ test('a number TTL is seconds from now', async () => {
27
+ const { resource } = await suite.boot()
28
+ await resource.create({ id: 'seconds', value: 'x' }, { ttl: 600 })
29
+
30
+ const remaining = await resource.db.client.pttl(resource.key('seconds'))
31
+
32
+ expect(remaining).toBeGreaterThan(600_000 - tolerance)
33
+ expect(remaining).toBeLessThanOrEqual(600_000)
34
+ })
35
+
36
+ test('a Date TTL is the absolute instant to expire at', async () => {
37
+ const { resource } = await suite.boot()
38
+ const expiresAt = new Date(Date.now() + 600_000)
39
+ await resource.create({ id: 'absolute', value: 'x' }, { ttl: expiresAt })
40
+
41
+ const remaining = await resource.db.client.pttl(resource.key('absolute'))
42
+
43
+ expect(remaining).toBeGreaterThan(600_000 - tolerance)
44
+ expect(remaining).toBeLessThanOrEqual(600_000)
45
+ })
46
+
47
+ test('update renews the expiry the same way', async () => {
48
+ const { resource } = await suite.boot()
49
+ await resource.create({ id: 'renewed', value: 'first' }, { ttl: 5 })
50
+ await resource.update({ id: 'renewed', value: 'second' }, { ttl: new Date(Date.now() + 600_000) })
51
+
52
+ const remaining = await resource.db.client.pttl(resource.key('renewed'))
53
+
54
+ expect(remaining).toBeGreaterThan(600_000 - tolerance)
55
+ expect(remaining).toBeLessThanOrEqual(600_000)
56
+ })
57
+
58
+ test('a Date in the past drops the record immediately', async () => {
59
+ const { resource } = await suite.boot()
60
+ await resource.create({ id: 'stale', value: 'x' }, { ttl: new Date(Date.now() - 1_000) })
61
+
62
+ expect(await resource.load('stale')).toBeNull()
63
+ })
64
+
65
+ test('no TTL leaves the record persistent', async () => {
66
+ const { resource } = await suite.boot()
67
+ await resource.create({ id: 'forever', value: 'x' })
68
+
69
+ // redis reports -1 for a key that exists with no expiry.
70
+ expect(await resource.db.client.pttl(resource.key('forever'))).toBe(-1)
71
+ })
72
+ })
package/tsconfig.json CHANGED
@@ -10,6 +10,7 @@
10
10
  "exclude": [
11
11
  "./dist/**/*",
12
12
  "./build/**/*",
13
+ "./tests/**/*",
13
14
  "./*.ts"
14
15
  ]
15
16
  }