odac 1.4.7 → 1.4.9

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 (54) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/client/odac.js +1 -1
  3. package/docs/ai/README.md +3 -2
  4. package/docs/ai/skills/SKILL.md +3 -2
  5. package/docs/ai/skills/backend/authentication.md +12 -6
  6. package/docs/ai/skills/backend/database.md +183 -12
  7. package/docs/ai/skills/backend/ipc.md +71 -12
  8. package/docs/ai/skills/backend/migrations.md +23 -0
  9. package/docs/ai/skills/backend/odac-var.md +155 -0
  10. package/docs/ai/skills/backend/utilities.md +1 -1
  11. package/docs/ai/skills/frontend/forms.md +23 -1
  12. package/docs/backend/04-routing/09-websocket-quick-reference.md +21 -1
  13. package/docs/backend/04-routing/09-websocket.md +22 -1
  14. package/docs/backend/08-database/05-write-behind-cache.md +230 -0
  15. package/docs/backend/08-database/06-read-through-cache.md +206 -0
  16. package/docs/backend/10-authentication/01-authentication-basics.md +53 -0
  17. package/docs/backend/10-authentication/05-session-management.md +12 -3
  18. package/docs/backend/13-utilities/01-odac-var.md +13 -19
  19. package/docs/backend/13-utilities/02-ipc.md +117 -0
  20. package/docs/frontend/03-forms/01-form-handling.md +15 -2
  21. package/docs/index.json +1 -1
  22. package/package.json +1 -1
  23. package/src/Auth.js +17 -0
  24. package/src/Database/Migration.js +219 -3
  25. package/src/Database/ReadCache.js +174 -0
  26. package/src/Database/WriteBuffer.js +605 -0
  27. package/src/Database.js +95 -1
  28. package/src/Ipc.js +343 -81
  29. package/src/Odac.js +2 -1
  30. package/src/Storage.js +4 -2
  31. package/src/Validator.js +1 -1
  32. package/src/Var.js +1 -0
  33. package/src/WebSocket.js +80 -23
  34. package/test/Database/Migration/migrate_column.test.js +168 -0
  35. package/test/Database/ReadCache/crossTable.test.js +179 -0
  36. package/test/Database/ReadCache/get.test.js +128 -0
  37. package/test/Database/ReadCache/invalidate.test.js +103 -0
  38. package/test/Database/ReadCache/proxy.test.js +184 -0
  39. package/test/Database/WriteBuffer/_recoverFromCheckpoint.test.js +207 -0
  40. package/test/Database/WriteBuffer/buffer.test.js +143 -0
  41. package/test/Database/WriteBuffer/flush.test.js +192 -0
  42. package/test/Database/WriteBuffer/get.test.js +72 -0
  43. package/test/Database/WriteBuffer/increment.test.js +118 -0
  44. package/test/Database/WriteBuffer/update.test.js +178 -0
  45. package/test/Database/insert.test.js +98 -0
  46. package/test/Ipc/hset.test.js +59 -0
  47. package/test/Ipc/incrBy.test.js +65 -0
  48. package/test/Ipc/lock.test.js +62 -0
  49. package/test/Ipc/rpush.test.js +68 -0
  50. package/test/Ipc/sadd.test.js +68 -0
  51. package/test/WebSocket/Client/fragmentation.test.js +130 -0
  52. package/test/WebSocket/Client/limits.test.js +10 -4
  53. package/test/WebSocket/Client/readyState.test.js +154 -0
  54. package/docs/backend/10-authentication/01-user-logins-with-authjs.md +0 -55
@@ -0,0 +1,59 @@
1
+ 'use strict'
2
+
3
+ const cluster = require('node:cluster')
4
+
5
+ /**
6
+ * Tests Ipc hash operations: hset() and hgetall().
7
+ * Why: Validates that hash merge semantics work correctly for Write-Behind Cache
8
+ * update coalescing (last-write-wins per field).
9
+ */
10
+
11
+ let Ipc
12
+
13
+ beforeEach(async () => {
14
+ jest.resetModules()
15
+ Object.defineProperty(cluster, 'isPrimary', {value: true, configurable: true})
16
+
17
+ Ipc = require('../../src/Ipc')
18
+ global.Odac = {Config: {}}
19
+ await Ipc.init()
20
+ })
21
+
22
+ afterEach(async () => {
23
+ await Ipc.close()
24
+ delete global.Odac
25
+ })
26
+
27
+ describe('Ipc - hset()', () => {
28
+ it('should store and retrieve hash fields', async () => {
29
+ await Ipc.hset('hash:a', {title: 'Hello', views: 100})
30
+ const result = await Ipc.hgetall('hash:a')
31
+ expect(result).toEqual({title: 'Hello', views: 100})
32
+ })
33
+
34
+ it('should merge new fields into existing hash', async () => {
35
+ await Ipc.hset('hash:b', {title: 'First'})
36
+ await Ipc.hset('hash:b', {slug: 'first-post'})
37
+ const result = await Ipc.hgetall('hash:b')
38
+ expect(result).toEqual({title: 'First', slug: 'first-post'})
39
+ })
40
+
41
+ it('should overwrite existing fields (last-write-wins)', async () => {
42
+ await Ipc.hset('hash:c', {title: 'Old'})
43
+ await Ipc.hset('hash:c', {title: 'New'})
44
+ const result = await Ipc.hgetall('hash:c')
45
+ expect(result).toEqual({title: 'New'})
46
+ })
47
+
48
+ it('should return null for non-existent hash', async () => {
49
+ const result = await Ipc.hgetall('hash:nonexistent')
50
+ expect(result).toBeNull()
51
+ })
52
+
53
+ it('should be deletable via del()', async () => {
54
+ await Ipc.hset('hash:d', {title: 'Test'})
55
+ await Ipc.del('hash:d')
56
+ const result = await Ipc.hgetall('hash:d')
57
+ expect(result).toBeNull()
58
+ })
59
+ })
@@ -0,0 +1,65 @@
1
+ 'use strict'
2
+
3
+ const cluster = require('node:cluster')
4
+
5
+ /**
6
+ * Tests Ipc.incrBy() atomic counter operation.
7
+ * Why: Validates that concurrent increment operations are atomic and return
8
+ * accurate accumulated values, essential for Write-Behind Cache counters.
9
+ */
10
+
11
+ let Ipc
12
+
13
+ beforeEach(async () => {
14
+ jest.resetModules()
15
+ Object.defineProperty(cluster, 'isPrimary', {value: true, configurable: true})
16
+
17
+ Ipc = require('../../src/Ipc')
18
+ global.Odac = {Config: {}}
19
+ await Ipc.init()
20
+ })
21
+
22
+ afterEach(async () => {
23
+ await Ipc.close()
24
+ delete global.Odac
25
+ })
26
+
27
+ describe('Ipc - incrBy()', () => {
28
+ it('should initialize and return delta on first call', async () => {
29
+ const result = await Ipc.incrBy('counter:a', 5)
30
+ expect(result).toBe(5)
31
+ })
32
+
33
+ it('should accumulate multiple increments', async () => {
34
+ await Ipc.incrBy('counter:b', 3)
35
+ await Ipc.incrBy('counter:b', 7)
36
+ const result = await Ipc.incrBy('counter:b', 2)
37
+ expect(result).toBe(12) // 3 + 7 + 2
38
+ })
39
+
40
+ it('should handle negative deltas (decrement)', async () => {
41
+ await Ipc.incrBy('counter:c', 10)
42
+ const result = await Ipc.incrBy('counter:c', -3)
43
+ expect(result).toBe(7)
44
+ })
45
+
46
+ it('should not interfere with different keys', async () => {
47
+ await Ipc.incrBy('counter:x', 5)
48
+ await Ipc.incrBy('counter:y', 10)
49
+
50
+ expect(await Ipc.get('counter:x')).toBe(5)
51
+ expect(await Ipc.get('counter:y')).toBe(10)
52
+ })
53
+
54
+ it('should be readable via get()', async () => {
55
+ await Ipc.incrBy('counter:d', 42)
56
+ const result = await Ipc.get('counter:d')
57
+ expect(result).toBe(42)
58
+ })
59
+
60
+ it('should work with decrBy()', async () => {
61
+ await Ipc.incrBy('counter:e', 10)
62
+ const result = await Ipc.decrBy('counter:e', 4)
63
+ expect(result).toBe(6)
64
+ })
65
+ })
@@ -0,0 +1,62 @@
1
+ 'use strict'
2
+
3
+ const cluster = require('node:cluster')
4
+
5
+ /**
6
+ * Tests Ipc distributed lock: lock() and unlock().
7
+ * Why: Validates that only one process can hold the flush lock at a time,
8
+ * preventing duplicate writes in horizontal scaling scenarios.
9
+ */
10
+
11
+ let Ipc
12
+
13
+ beforeEach(async () => {
14
+ jest.resetModules()
15
+ Object.defineProperty(cluster, 'isPrimary', {value: true, configurable: true})
16
+
17
+ Ipc = require('../../src/Ipc')
18
+ global.Odac = {Config: {}}
19
+ await Ipc.init()
20
+ })
21
+
22
+ afterEach(async () => {
23
+ await Ipc.close()
24
+ delete global.Odac
25
+ })
26
+
27
+ describe('Ipc - lock()', () => {
28
+ it('should acquire lock and return true', async () => {
29
+ const result = await Ipc.lock('lock:a', 5)
30
+ expect(result).toBe(true)
31
+ })
32
+
33
+ it('should reject second acquisition (mutex)', async () => {
34
+ await Ipc.lock('lock:b', 5)
35
+ const result = await Ipc.lock('lock:b', 5)
36
+ expect(result).toBe(false)
37
+ })
38
+
39
+ it('should allow re-acquisition after unlock', async () => {
40
+ await Ipc.lock('lock:c', 5)
41
+ await Ipc.unlock('lock:c')
42
+ const result = await Ipc.lock('lock:c', 5)
43
+ expect(result).toBe(true)
44
+ })
45
+
46
+ it('should auto-expire after TTL', async () => {
47
+ await Ipc.lock('lock:d', 1) // 1 second TTL
48
+
49
+ // Wait for expiry
50
+ await new Promise(r => setTimeout(r, 1100))
51
+
52
+ // Lock should be available again
53
+ const result = await Ipc.lock('lock:d', 5)
54
+ expect(result).toBe(true)
55
+ })
56
+
57
+ it('should not interfere with different lock keys', async () => {
58
+ await Ipc.lock('lock:e', 5)
59
+ const result = await Ipc.lock('lock:f', 5)
60
+ expect(result).toBe(true)
61
+ })
62
+ })
@@ -0,0 +1,68 @@
1
+ 'use strict'
2
+
3
+ const cluster = require('node:cluster')
4
+
5
+ /**
6
+ * Tests Ipc list operations: rpush() and lrange().
7
+ * Why: Validates that batch insert queue (Write-Behind Cache) correctly appends
8
+ * items and retrieves them in order.
9
+ */
10
+
11
+ let Ipc
12
+
13
+ beforeEach(async () => {
14
+ jest.resetModules()
15
+ Object.defineProperty(cluster, 'isPrimary', {value: true, configurable: true})
16
+
17
+ Ipc = require('../../src/Ipc')
18
+ global.Odac = {Config: {}}
19
+ await Ipc.init()
20
+ })
21
+
22
+ afterEach(async () => {
23
+ await Ipc.close()
24
+ delete global.Odac
25
+ })
26
+
27
+ describe('Ipc - rpush()', () => {
28
+ it('should append items and return new length', async () => {
29
+ const len1 = await Ipc.rpush('list:a', {action: 'view'})
30
+ const len2 = await Ipc.rpush('list:a', {action: 'click'})
31
+ expect(len1).toBe(1)
32
+ expect(len2).toBe(2)
33
+ })
34
+
35
+ it('should append multiple items at once', async () => {
36
+ const len = await Ipc.rpush('list:b', {a: 1}, {a: 2}, {a: 3})
37
+ expect(len).toBe(3)
38
+ })
39
+ })
40
+
41
+ describe('Ipc - lrange()', () => {
42
+ it('should return all items with 0, -1', async () => {
43
+ await Ipc.rpush('list:c', 'first')
44
+ await Ipc.rpush('list:c', 'second')
45
+ await Ipc.rpush('list:c', 'third')
46
+
47
+ const result = await Ipc.lrange('list:c', 0, -1)
48
+ expect(result).toEqual(['first', 'second', 'third'])
49
+ })
50
+
51
+ it('should return a range of items', async () => {
52
+ await Ipc.rpush('list:d', 'a', 'b', 'c', 'd')
53
+ const result = await Ipc.lrange('list:d', 1, 2)
54
+ expect(result).toEqual(['b', 'c'])
55
+ })
56
+
57
+ it('should return empty array for non-existent list', async () => {
58
+ const result = await Ipc.lrange('list:nonexistent', 0, -1)
59
+ expect(result).toEqual([])
60
+ })
61
+
62
+ it('should clear list on del()', async () => {
63
+ await Ipc.rpush('list:e', 'item')
64
+ await Ipc.del('list:e')
65
+ const result = await Ipc.lrange('list:e', 0, -1)
66
+ expect(result).toEqual([])
67
+ })
68
+ })
@@ -0,0 +1,68 @@
1
+ 'use strict'
2
+
3
+ const cluster = require('node:cluster')
4
+
5
+ /**
6
+ * Tests Ipc set operations: sadd(), smembers(), srem().
7
+ * Why: WriteBuffer uses index sets to track active counter/update/queue keys
8
+ * for efficient flush discovery without expensive SCAN operations.
9
+ */
10
+
11
+ let Ipc
12
+
13
+ beforeEach(async () => {
14
+ jest.resetModules()
15
+ Object.defineProperty(cluster, 'isPrimary', {value: true, configurable: true})
16
+
17
+ Ipc = require('../../src/Ipc')
18
+ global.Odac = {Config: {}}
19
+ await Ipc.init()
20
+ })
21
+
22
+ afterEach(async () => {
23
+ await Ipc.close()
24
+ delete global.Odac
25
+ })
26
+
27
+ describe('Ipc - sadd()', () => {
28
+ it('should add members and return count of new additions', async () => {
29
+ const added = await Ipc.sadd('set:a', 'x', 'y', 'z')
30
+ expect(added).toBe(3)
31
+ })
32
+
33
+ it('should not duplicate existing members', async () => {
34
+ await Ipc.sadd('set:b', 'x', 'y')
35
+ const added = await Ipc.sadd('set:b', 'y', 'z')
36
+ expect(added).toBe(1) // Only 'z' is new
37
+ })
38
+ })
39
+
40
+ describe('Ipc - smembers()', () => {
41
+ it('should return all members', async () => {
42
+ await Ipc.sadd('set:c', 'a', 'b', 'c')
43
+ const members = await Ipc.smembers('set:c')
44
+ expect(members.sort()).toEqual(['a', 'b', 'c'])
45
+ })
46
+
47
+ it('should return empty array for non-existent set', async () => {
48
+ const result = await Ipc.smembers('set:nonexistent')
49
+ expect(result).toEqual([])
50
+ })
51
+ })
52
+
53
+ describe('Ipc - srem()', () => {
54
+ it('should remove specified members', async () => {
55
+ await Ipc.sadd('set:d', 'a', 'b', 'c')
56
+ const removed = await Ipc.srem('set:d', 'b')
57
+ expect(removed).toBe(1)
58
+
59
+ const remaining = await Ipc.smembers('set:d')
60
+ expect(remaining.sort()).toEqual(['a', 'c'])
61
+ })
62
+
63
+ it('should return 0 for non-existent members', async () => {
64
+ await Ipc.sadd('set:e', 'x')
65
+ const removed = await Ipc.srem('set:e', 'nonexistent')
66
+ expect(removed).toBe(0)
67
+ })
68
+ })
@@ -0,0 +1,130 @@
1
+ const {WebSocketServer, WebSocketClient} = require('../../../src/WebSocket.js')
2
+
3
+ /**
4
+ * Builds a masked WebSocket frame from raw payload bytes.
5
+ * Uses a zero mask key for deterministic test output.
6
+ */
7
+ function buildFrame(opcode, payload, fin = true) {
8
+ const buf = Buffer.isBuffer(payload) ? payload : Buffer.from(payload)
9
+ const maskKey = Buffer.alloc(4) // zero mask — XOR is identity
10
+ const masked = Buffer.from(buf)
11
+
12
+ const finBit = fin ? 0x80 : 0x00
13
+ const header = Buffer.alloc(2 + 4 + buf.length)
14
+ header[0] = finBit | opcode
15
+ header[1] = 0x80 | buf.length // masked bit + length
16
+ maskKey.copy(header, 2)
17
+ masked.copy(header, 6)
18
+
19
+ return header
20
+ }
21
+
22
+ function createMockSocket() {
23
+ return {
24
+ pause: jest.fn(),
25
+ resume: jest.fn(),
26
+ on: jest.fn(),
27
+ write: jest.fn(),
28
+ end: jest.fn(),
29
+ removeAllListeners: jest.fn(),
30
+ writable: true
31
+ }
32
+ }
33
+
34
+ describe('WebSocketClient Fragmentation', () => {
35
+ let server
36
+
37
+ beforeEach(() => {
38
+ server = new WebSocketServer()
39
+ })
40
+
41
+ it('should reassemble fragmented text messages', () => {
42
+ const socket = createMockSocket()
43
+ const client = new WebSocketClient(socket, server, 'frag-1')
44
+ client.resume()
45
+
46
+ const messages = []
47
+ client.on('message', msg => messages.push(msg))
48
+
49
+ const dataHandler = socket.on.mock.calls.find(c => c[0] === 'data')[1]
50
+
51
+ // Fragment 1: TEXT opcode, fin=false
52
+ dataHandler(buildFrame(0x1, 'hel', false))
53
+ // Fragment 2: CONTINUATION opcode, fin=false
54
+ dataHandler(buildFrame(0x0, 'lo ', false))
55
+ // Fragment 3: CONTINUATION opcode, fin=true
56
+ dataHandler(buildFrame(0x0, 'world', true))
57
+
58
+ expect(messages).toEqual(['hello world'])
59
+ })
60
+
61
+ it('should reassemble fragmented binary messages', () => {
62
+ const socket = createMockSocket()
63
+ const client = new WebSocketClient(socket, server, 'frag-2')
64
+ client.resume()
65
+
66
+ const messages = []
67
+ client.on('message', msg => messages.push(msg))
68
+
69
+ const dataHandler = socket.on.mock.calls.find(c => c[0] === 'data')[1]
70
+
71
+ const part1 = Buffer.from([0x01, 0x02])
72
+ const part2 = Buffer.from([0x03, 0x04])
73
+
74
+ // Fragment 1: BINARY opcode, fin=false
75
+ dataHandler(buildFrame(0x2, part1, false))
76
+ // Fragment 2: CONTINUATION opcode, fin=true
77
+ dataHandler(buildFrame(0x0, part2, true))
78
+
79
+ expect(messages.length).toBe(1)
80
+ expect(Buffer.isBuffer(messages[0])).toBe(true)
81
+ expect(messages[0]).toEqual(Buffer.from([0x01, 0x02, 0x03, 0x04]))
82
+ })
83
+
84
+ it('should close with 1002 on unexpected continuation frame', () => {
85
+ const socket = createMockSocket()
86
+ const client = new WebSocketClient(socket, server, 'frag-3')
87
+ client.resume()
88
+
89
+ const dataHandler = socket.on.mock.calls.find(c => c[0] === 'data')[1]
90
+
91
+ // Send CONTINUATION without a preceding TEXT/BINARY
92
+ dataHandler(buildFrame(0x0, 'orphan', true))
93
+
94
+ expect(socket.end).toHaveBeenCalled()
95
+ })
96
+
97
+ it('should handle single unfragmented message normally', () => {
98
+ const socket = createMockSocket()
99
+ const client = new WebSocketClient(socket, server, 'frag-4')
100
+ client.resume()
101
+
102
+ const messages = []
103
+ client.on('message', msg => messages.push(msg))
104
+
105
+ const dataHandler = socket.on.mock.calls.find(c => c[0] === 'data')[1]
106
+
107
+ // Single complete frame: TEXT, fin=true
108
+ dataHandler(buildFrame(0x1, 'complete', true))
109
+
110
+ expect(messages).toEqual(['complete'])
111
+ })
112
+
113
+ it('should discard fragment buffer on close', () => {
114
+ const socket = createMockSocket()
115
+ const client = new WebSocketClient(socket, server, 'frag-5')
116
+ client.resume()
117
+
118
+ const messages = []
119
+ client.on('message', msg => messages.push(msg))
120
+
121
+ const dataHandler = socket.on.mock.calls.find(c => c[0] === 'data')[1]
122
+
123
+ // Start a fragmented message but close before completion
124
+ dataHandler(buildFrame(0x1, 'partial', false))
125
+ client.close()
126
+
127
+ // No message should have been emitted
128
+ expect(messages).toEqual([])
129
+ })
130
+ })
@@ -11,12 +11,15 @@ describe('WebSocketClient Limits', () => {
11
11
  it('should close connection if payload exceeds limit', () => {
12
12
  const socket = {
13
13
  pause: jest.fn(),
14
+ resume: jest.fn(),
14
15
  on: jest.fn(),
15
16
  write: jest.fn(),
16
17
  end: jest.fn(),
17
- removeAllListeners: jest.fn()
18
+ removeAllListeners: jest.fn(),
19
+ writable: true
18
20
  }
19
- new WebSocketClient(socket, server, 'test-id', {maxPayload: 10})
21
+ const client = new WebSocketClient(socket, server, 'test-id', {maxPayload: 10})
22
+ client.resume()
20
23
 
21
24
  const buffer = Buffer.alloc(100)
22
25
  buffer[0] = 0x81
@@ -32,12 +35,15 @@ describe('WebSocketClient Limits', () => {
32
35
  it('should close connection if rate limit exceeded', () => {
33
36
  const socket = {
34
37
  pause: jest.fn(),
38
+ resume: jest.fn(),
35
39
  on: jest.fn(),
36
40
  write: jest.fn(),
37
41
  end: jest.fn(),
38
- removeAllListeners: jest.fn()
42
+ removeAllListeners: jest.fn(),
43
+ writable: true
39
44
  }
40
- new WebSocketClient(socket, server, 'test-id', {rateLimit: {max: 2, window: 1000}})
45
+ const client = new WebSocketClient(socket, server, 'test-id', {rateLimit: {max: 2, window: 1000}})
46
+ client.resume()
41
47
 
42
48
  const buffer = Buffer.alloc(7)
43
49
  buffer[0] = 0x81
@@ -0,0 +1,154 @@
1
+ const {WebSocketServer, WebSocketClient, READY_STATE} = require('../../../src/WebSocket.js')
2
+
3
+ /**
4
+ * Helper: creates a mock TCP socket with the minimum interface
5
+ * required by WebSocketClient.
6
+ */
7
+ function createMockSocket() {
8
+ return {
9
+ pause: jest.fn(),
10
+ resume: jest.fn(),
11
+ on: jest.fn(),
12
+ write: jest.fn(),
13
+ end: jest.fn(),
14
+ removeAllListeners: jest.fn(),
15
+ writable: true
16
+ }
17
+ }
18
+
19
+ describe('WebSocketClient readyState', () => {
20
+ let server
21
+
22
+ beforeEach(() => {
23
+ server = new WebSocketServer()
24
+ })
25
+
26
+ it('should expose static state constants matching READY_STATE enum', () => {
27
+ expect(WebSocketClient.CONNECTING).toBe(0)
28
+ expect(WebSocketClient.OPEN).toBe(1)
29
+ expect(WebSocketClient.CLOSING).toBe(2)
30
+ expect(WebSocketClient.CLOSED).toBe(3)
31
+ })
32
+
33
+ it('should export READY_STATE enum', () => {
34
+ expect(READY_STATE).toEqual({
35
+ CONNECTING: 0,
36
+ OPEN: 1,
37
+ CLOSING: 2,
38
+ CLOSED: 3
39
+ })
40
+ })
41
+
42
+ it('should start in CONNECTING state', () => {
43
+ const socket = createMockSocket()
44
+ const client = new WebSocketClient(socket, server, 'rs-1')
45
+ expect(client.readyState).toBe(READY_STATE.CONNECTING)
46
+ })
47
+
48
+ it('should transition to OPEN on resume()', () => {
49
+ const socket = createMockSocket()
50
+ const client = new WebSocketClient(socket, server, 'rs-2')
51
+
52
+ client.resume()
53
+
54
+ expect(client.readyState).toBe(READY_STATE.OPEN)
55
+ expect(socket.resume).toHaveBeenCalled()
56
+ })
57
+
58
+ it('should transition to CLOSED after close()', () => {
59
+ const socket = createMockSocket()
60
+ const client = new WebSocketClient(socket, server, 'rs-3')
61
+ client.resume()
62
+
63
+ client.close()
64
+
65
+ expect(client.readyState).toBe(READY_STATE.CLOSED)
66
+ expect(socket.end).toHaveBeenCalled()
67
+ })
68
+
69
+ it('should be idempotent — second close() is a no-op', () => {
70
+ const socket = createMockSocket()
71
+ const client = new WebSocketClient(socket, server, 'rs-4')
72
+ client.resume()
73
+
74
+ client.close()
75
+ client.close()
76
+
77
+ expect(socket.end).toHaveBeenCalledTimes(1)
78
+ })
79
+
80
+ it('should not send data when in CONNECTING state', () => {
81
+ const socket = createMockSocket()
82
+ const client = new WebSocketClient(socket, server, 'rs-5')
83
+
84
+ client.send('hello')
85
+
86
+ expect(socket.write).not.toHaveBeenCalled()
87
+ })
88
+
89
+ it('should not send data when in CLOSED state', () => {
90
+ const socket = createMockSocket()
91
+ const client = new WebSocketClient(socket, server, 'rs-6')
92
+ client.resume()
93
+ client.close()
94
+
95
+ client.send('hello')
96
+
97
+ // Only the close frame write should exist, no data frame
98
+ const writes = socket.write.mock.calls
99
+ const lastWrite = writes[writes.length - 1]
100
+ // Close frame starts with 0x88
101
+ expect(lastWrite[0][0]).toBe(0x88)
102
+ })
103
+
104
+ it('should not send ping when not OPEN', () => {
105
+ const socket = createMockSocket()
106
+ const client = new WebSocketClient(socket, server, 'rs-7')
107
+
108
+ client.ping()
109
+
110
+ expect(socket.write).not.toHaveBeenCalled()
111
+ })
112
+
113
+ it('should not write when socket is not writable', () => {
114
+ const socket = createMockSocket()
115
+ socket.writable = false
116
+ const client = new WebSocketClient(socket, server, 'rs-8')
117
+ client.resume()
118
+
119
+ client.send('hello')
120
+
121
+ expect(socket.write).not.toHaveBeenCalled()
122
+ })
123
+
124
+ it('should transition to CLOSED when socket fires close event', () => {
125
+ const socket = createMockSocket()
126
+ const client = new WebSocketClient(socket, server, 'rs-9')
127
+ server.clients.set('rs-9', client)
128
+ client.resume()
129
+
130
+ // Simulate socket 'close' event
131
+ const closeHandler = socket.on.mock.calls.find(c => c[0] === 'close')[1]
132
+ closeHandler()
133
+
134
+ expect(client.readyState).toBe(READY_STATE.CLOSED)
135
+ })
136
+
137
+ it('should emit close event only once on double cleanup', () => {
138
+ const socket = createMockSocket()
139
+ const client = new WebSocketClient(socket, server, 'rs-10')
140
+ server.clients.set('rs-10', client)
141
+ client.resume()
142
+
143
+ const closeSpy = jest.fn()
144
+ client.on('close', closeSpy)
145
+
146
+ client.close()
147
+
148
+ // Simulate socket 'close' event firing after close() already cleaned up
149
+ const closeHandler = socket.on.mock.calls.find(c => c[0] === 'close')
150
+ if (closeHandler) closeHandler[1]()
151
+
152
+ expect(closeSpy).toHaveBeenCalledTimes(1)
153
+ })
154
+ })
@@ -1,55 +0,0 @@
1
- ## 🔐 User Logins with `Auth.js`
2
-
3
- The `Odac.Auth` service is your bouncer, managing who gets in and who stays out. It handles user login sessions for you.
4
-
5
- #### Letting a User In
6
-
7
- `Odac.Auth.login(userId, userData)`
8
-
9
- * `userId`: A unique ID for the user (like their database ID).
10
- * `userData`: An object with any user info you want to remember, like their username or role.
11
-
12
- When you call this, `Auth` creates a secure session for the user.
13
-
14
- > **💡 Enterprise Security:** ODAC automatically handles **Token Rotation** every 15 minutes (configurable) and includes built-in **CSRF protection** for all forms. Sessions are persistent across browser restarts by default.
15
-
16
- #### Checking the Guest List
17
-
18
- * `Odac.Auth.isLogin()`: Is the current user logged in? Returns `true` or `false`.
19
- * `Odac.Auth.getId()`: Gets the ID of the logged-in user.
20
- * `Odac.Auth.get('some-key')`: Grabs a specific piece of info from the `userData` you stored.
21
-
22
- #### Showing a User Out
23
-
24
- * `Odac.Auth.logout()`: Ends the user's session and logs them out.
25
-
26
- #### Example: A Login Flow
27
- ```javascript
28
- // Controller for your login form
29
- module.exports = async function (Odac) {
30
- const { username, password } = Odac.Request.post;
31
-
32
- // IMPORTANT: You need to write your own code to find the user in your database!
33
- const user = await yourDatabase.findUser(username, password);
34
-
35
- if (user) {
36
- // User is valid! Log them in.
37
- Odac.Auth.login(user.id, { username: user.username });
38
- return Odac.direct('/dashboard'); // Send them to their dashboard
39
- } else {
40
- // Bad credentials, send them back to the login page
41
- return Odac.direct('/login?error=1');
42
- }
43
- }
44
-
45
- // A protected dashboard page
46
- module.exports = function (Odac) {
47
- // If they're not logged in, kick them back to the login page.
48
- if (!Odac.Auth.isLogin()) {
49
- return Odac.direct('/login');
50
- }
51
-
52
- const username = Odac.Auth.get('username');
53
- return `Welcome back, ${username}!`;
54
- }
55
- ```